mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-13 03:13:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2b3f6df5 | ||
|
|
785313cc21 | ||
|
|
41f1e08bec | ||
|
|
4158e05dae | ||
|
|
e356c2ed85 | ||
|
|
ed940db4f0 | ||
|
|
755af87095 | ||
|
|
b9aa47d1a5 | ||
|
|
0d84ec6dab | ||
|
|
b769a9765f | ||
|
|
9c4c66e70f | ||
|
|
42858c61df | ||
|
|
470969dc74 | ||
|
|
be7ddcd15a | ||
|
|
c3c647ef83 | ||
|
|
160e18e705 | ||
|
|
20c8b4abc4 | ||
|
|
ccb9afd7a5 | ||
|
|
49e34813ae | ||
|
|
e0ef637805 | ||
|
|
8502663d0e | ||
|
|
4e8037a93b | ||
|
|
76110557ab | ||
|
|
3bc5cc03f8 | ||
|
|
de961b91eb | ||
|
|
ad9eeb668a | ||
|
|
b741335222 | ||
|
|
20143c9558 | ||
|
|
8b14a38d4a | ||
|
|
179e435cd0 | ||
|
|
f963e9cfe8 | ||
|
|
e5aa9add02 | ||
|
|
329115b148 | ||
|
|
ff30f39b1b | ||
|
|
3802feb3fe | ||
|
|
27e9b4de87 | ||
|
|
4bedadef9d | ||
|
|
83b63250a3 | ||
|
|
2e59141ae9 | ||
|
|
020f0d2861 | ||
|
|
711b72735b | ||
|
|
436d0d2942 | ||
|
|
a284a62d96 | ||
|
|
2b1e64e414 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
|
|||||||
@@ -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,7 +28,7 @@ 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:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # 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
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ linters:
|
|||||||
deny:
|
deny:
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
|
logging:
|
||||||
|
files:
|
||||||
|
- $all
|
||||||
|
- "!**/act/common/logging/**"
|
||||||
|
deny:
|
||||||
|
- pkg: log/slog
|
||||||
|
desc: log through gitea.com/gitea/runner/act/common/logging
|
||||||
nolintlint:
|
nolintlint:
|
||||||
allow-unused: false
|
allow-unused: false
|
||||||
require-explanation: true
|
require-explanation: true
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ 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.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Log through `act/common/logging`; the linter rejects importing `log/slog` elsewhere.
|
||||||
|
A job's logger comes from `common.Logger(ctx)`; code with no job uses the package functions
|
||||||
|
(`logging.Infof`, `logging.With`). Both take printf-style messages, and `With("key", value)` adds
|
||||||
|
fields.
|
||||||
|
|
||||||
|
- The process-wide logger masks the secrets of every live task, the job logger masks its own.
|
||||||
|
Going around either leaks them.
|
||||||
|
- The job logger's output can reach the job log workflow authors see, the process-wide logger's
|
||||||
|
stays on the runner host, so keep operator-only detail (host paths, internal addresses) off the
|
||||||
|
job logger.
|
||||||
|
- Libraries that log through slog's global logger are forwarded to the process-wide one, so they
|
||||||
|
follow its level and masking too. Avoid dependencies that log through logrus, nothing forwards it.
|
||||||
|
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||||
|
package functions.
|
||||||
|
|
||||||
## Built-in actions
|
## Built-in actions
|
||||||
|
|
||||||
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.27-alpine3.23 AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind AS dind
|
FROM docker:29.9.1-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind-rootless AS dind-rootless
|
FROM docker:29.9.1-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := gitea-runner
|
EXECUTABLE := gitea-runner
|
||||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
|
||||||
GO ?= go
|
GO ?= go
|
||||||
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_VERSION := go-1.27.x
|
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
|
||||||
|
|
||||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # 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
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
|
||||||
WINDOWS_ARCHS ?= windows/amd64
|
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|
||||||
DOCKER_IMAGE ?= gitea/runner
|
DOCKER_IMAGE ?= gitea/runner
|
||||||
@@ -24,7 +16,7 @@ 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/[email protected] # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
@@ -38,9 +30,6 @@ endif
|
|||||||
ifeq ($(HAS_GO), GO)
|
ifeq ($(HAS_GO), GO)
|
||||||
GOPATH ?= $(shell $(GO) env GOPATH)
|
GOPATH ?= $(shell $(GO) env GOPATH)
|
||||||
export PATH := $(GOPATH)/bin:$(PATH)
|
export PATH := $(GOPATH)/bin:$(PATH)
|
||||||
|
|
||||||
CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766
|
|
||||||
CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS)
|
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
@@ -108,8 +97,6 @@ fmt-check: ## check the Go code formatting
|
|||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \
|
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \
|
||||||
$(GO) install $(GXZ_PACKAGE) & \
|
|
||||||
$(GO) install $(XGO_PACKAGE) & \
|
|
||||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
@@ -187,45 +174,6 @@ $(EXECUTABLE): $(GOFILES)
|
|||||||
deps-backend: ## install backend dependencies
|
deps-backend: ## install backend dependencies
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
|
|
||||||
.PHONY: release
|
|
||||||
release: release-windows release-linux release-darwin release-copy release-compress release-check ## build release artifacts
|
|
||||||
|
|
||||||
$(DIST_DIRS):
|
|
||||||
mkdir -p $(DIST_DIRS)
|
|
||||||
|
|
||||||
.PHONY: release-windows
|
|
||||||
release-windows: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-linux
|
|
||||||
release-linux: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-darwin
|
|
||||||
release-darwin: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w $(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-copy
|
|
||||||
release-copy: | $(DIST_DIRS)
|
|
||||||
cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done;
|
|
||||||
|
|
||||||
.PHONY: release-check
|
|
||||||
release-check: | $(DIST_DIRS)
|
|
||||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;
|
|
||||||
|
|
||||||
.PHONY: release-compress
|
|
||||||
release-compress: | $(DIST_DIRS)
|
|
||||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done;
|
|
||||||
|
|
||||||
.PHONY: docker
|
.PHONY: docker
|
||||||
docker: ## build the docker image
|
docker: ## build the docker image
|
||||||
if ! docker buildx version >/dev/null 2>&1; then \
|
if ! docker buildx version >/dev/null 2>&1; then \
|
||||||
|
|||||||
@@ -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 clone reusable workflows, and actions whose host serves no archive of them.
|
||||||
|
- `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
|
||||||
|
|
||||||
@@ -157,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
|
||||||
|
|
||||||
@@ -205,7 +217,7 @@ a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubunt
|
|||||||
|
|
||||||
Names may themselves contain a colon, for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`. Only `host`, `docker` and `kubernetes` 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`: as a container when the runner has `docker` labels, as a pod when it has `kubernetes` labels, else as a container where docker is available and 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.
|
||||||
|
|
||||||
@@ -230,7 +242,7 @@ A job's `services` are started before its steps run. When a service's image or i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:18
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
@@ -252,7 +264,7 @@ 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
|
||||||
|
|
||||||
@@ -356,7 +368,7 @@ While the runner is idle it cleans up after earlier jobs:
|
|||||||
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||||
- 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 and volumes left behind by jobs the runner did not live to tear down are removed once unused and 10 minutes old, 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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -30,10 +29,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/internal/pkg/disk"
|
"gitea.com/gitea/runner/internal/pkg/disk"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/timshannon/bolthold"
|
"github.com/timshannon/bolthold"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -81,11 +79,11 @@ type Handler struct {
|
|||||||
dir string
|
dir string
|
||||||
storage cacheStorage
|
storage cacheStorage
|
||||||
s3 *s3Storage
|
s3 *s3Storage
|
||||||
router *httprouter.Router
|
router *http.ServeMux
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
server *http.Server
|
server *http.Server
|
||||||
logger logrus.FieldLogger
|
logger *logging.Logger
|
||||||
|
|
||||||
gcing atomic.Bool
|
gcing atomic.Bool
|
||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
@@ -133,7 +131,7 @@ type Options struct {
|
|||||||
InternalSecret string
|
InternalSecret string
|
||||||
|
|
||||||
Policy Policy
|
Policy Policy
|
||||||
Logger logrus.FieldLogger
|
Logger *logging.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
||||||
@@ -147,11 +145,9 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
discard := logrus.New()
|
logger = logging.New(logging.DiscardHandler)
|
||||||
discard.Out = io.Discard
|
|
||||||
logger = discard
|
|
||||||
}
|
}
|
||||||
logger = logger.WithField("module", "artifactcache")
|
logger = logger.With("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if opts.OutboundIP != "" {
|
||||||
@@ -204,22 +200,22 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
h.secret = secret
|
h.secret = secret
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
|
router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find))
|
||||||
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
|
router.HandleFunc("POST "+apiPath+"/caches", h.bearerAuth(h.reserve))
|
||||||
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
|
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.bearerAuth(h.upload))
|
||||||
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
|
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.bearerAuth(h.commit))
|
||||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean))
|
||||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||||
// does not attach Authorization when downloading archiveLocation.
|
// does not attach Authorization when downloading archiveLocation.
|
||||||
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
router.HandleFunc("GET "+apiPath+"/artifacts/{id}", h.signedAuth("", h.get))
|
||||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||||
// cache API can authenticate them. Always wired so the routes exist; the
|
// cache API can authenticate them. Always wired so the routes exist; the
|
||||||
// handlers themselves 401 when internalSecret is unset.
|
// handlers themselves 401 when internalSecret is unset.
|
||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
router.HandleFunc("POST "+internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
router.HandleFunc("POST "+internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||||
h.registerV2Routes(router)
|
h.registerV2Routes(router)
|
||||||
router.NotFound = http.HandlerFunc(h.forwardOrNotFound)
|
router.HandleFunc(artifactServicePath, h.forwardOrNotFound)
|
||||||
|
|
||||||
h.router = router
|
h.router = router
|
||||||
|
|
||||||
@@ -501,7 +497,7 @@ func (h *Handler) importMetadata(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||||
version := r.URL.Query().Get("version")
|
version := r.URL.Query().Get("version")
|
||||||
@@ -552,7 +548,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// POST /_apis/artifactcache/caches
|
||||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
api := &Request{}
|
api := &Request{}
|
||||||
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
||||||
@@ -584,9 +580,9 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /_apis/artifactcache/caches/:id
|
// PATCH /_apis/artifactcache/caches/:id
|
||||||
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -632,9 +628,9 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches/:id
|
// POST /_apis/artifactcache/caches/:id
|
||||||
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -719,8 +715,8 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||||
// Repository scoping is already enforced at find() time; the signature binds
|
// Repository scoping is already enforced at find() time; the signature binds
|
||||||
// the URL to the specific cache ID and an expiry.
|
// the URL to the specific cache ID and an expiry.
|
||||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -730,7 +726,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/clean
|
// POST /_apis/artifactcache/clean
|
||||||
func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) clean(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: don't support force deleting cache entries
|
// TODO: don't support force deleting cache entries
|
||||||
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||||
|
|
||||||
@@ -740,8 +736,8 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
|
|||||||
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
||||||
// registered jobs. A match attaches the job's JobCredential to the request
|
// registered jobs. A match attaches the job's JobCredential to the request
|
||||||
// context; a miss returns 401 before the handler body runs.
|
// context; a miss returns 401 before the handler body runs.
|
||||||
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) bearerAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
token := bearerToken(r)
|
token := bearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -754,7 +750,7 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
handler(w, r.WithContext(ctx), params)
|
handler(w, r.WithContext(ctx))
|
||||||
go h.gcCache()
|
go h.gcCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,10 +765,10 @@ func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error
|
|||||||
|
|
||||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) signedAuth(purpose string, handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -797,7 +793,7 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout
|
|||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler(w, r, params)
|
handler(w, r)
|
||||||
go h.gcCache()
|
go h.gcCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,8 +802,8 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout
|
|||||||
// constant-time-equal the configured internalSecret. If the secret is empty,
|
// constant-time-equal the configured internalSecret. If the secret is empty,
|
||||||
// the control-plane is disabled and every request gets 404 — which matches
|
// the control-plane is disabled and every request gets 404 — which matches
|
||||||
// the upstream nektos/act behavior of "the route does not exist".
|
// the upstream nektos/act behavior of "the route does not exist".
|
||||||
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) internalAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.internalSecret == "" {
|
if h.internalSecret == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -817,7 +813,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler(w, r, params)
|
handler(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +836,7 @@ func (h *Handler) ResultsURL(cred JobCredential) string {
|
|||||||
return h.baseURL(cred)
|
return h.baseURL(cred)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
var body internalRegisterBody
|
var body internalRegisterBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
@@ -855,7 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/revoke
|
// POST /_internal/revoke
|
||||||
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
var body internalRevokeBody
|
var body internalRevokeBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The cache service v2 API. A client on this version talks twirp to
|
// The cache service v2 API. A client on this version talks twirp to
|
||||||
@@ -44,16 +42,16 @@ const (
|
|||||||
twirpUnauthenticated = "unauthenticated"
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *http.ServeMux) {
|
||||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||||
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||||
}
|
}
|
||||||
|
|
||||||
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||||
// the upload.
|
// the upload.
|
||||||
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,7 +121,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,7 +170,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,8 +205,8 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||||
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||||
// in order.
|
// in order.
|
||||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
|||||||
+16
-22
@@ -19,8 +19,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileContainerResourceURL struct {
|
type FileContainerResourceURL struct {
|
||||||
@@ -67,24 +65,21 @@ func writeJSON(w http.ResponseWriter, value any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploads(router *httprouter.Router, baseDir string) {
|
func uploads(router *http.ServeMux, baseDir string) {
|
||||||
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
||||||
runID := params.ByName("runId")
|
|
||||||
|
|
||||||
writeJSON(w, FileContainerResourceURL{
|
writeJSON(w, FileContainerResourceURL{
|
||||||
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, req.PathValue("runId")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("PUT /upload/{runId}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
itemPath := req.URL.Query().Get("itemPath")
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
runID := params.ByName("runId")
|
|
||||||
|
|
||||||
if req.Header.Get("Content-Encoding") == "gzip" {
|
if req.Header.Get("Content-Encoding") == "gzip" {
|
||||||
itemPath += gzipExtension
|
itemPath += gzipExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
safeRunPath := safeResolve(baseDir, runID)
|
safeRunPath := safeResolve(baseDir, req.PathValue("runId"))
|
||||||
safePath := safeResolve(safeRunPath, itemPath)
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
||||||
@@ -114,16 +109,16 @@ func uploads(router *httprouter.Router, baseDir string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("PATCH /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, _req *http.Request) {
|
||||||
writeJSON(w, ResponseMessage{
|
writeJSON(w, ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloads(router *httprouter.Router, baseDir string) {
|
func downloads(router *http.ServeMux, baseDir string) {
|
||||||
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
||||||
runID := params.ByName("runId")
|
runID := req.PathValue("runId")
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, runID)
|
safePath := safeResolve(baseDir, runID)
|
||||||
|
|
||||||
@@ -146,8 +141,8 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("GET /download/{container}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
container := params.ByName("container")
|
container := req.PathValue("container")
|
||||||
itemPath := req.URL.Query().Get("itemPath")
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
@@ -183,10 +178,8 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("GET /artifact/{path...}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
path := params.ByName("path")[1:]
|
safePath := safeResolve(baseDir, req.PathValue("path"))
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
|
||||||
|
|
||||||
file, err := os.Open(safePath)
|
file, err := os.Open(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,7 +207,7 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
return cancel
|
return cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
|
|
||||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||||
uploads(router, artifactPath)
|
uploads(router, artifactPath)
|
||||||
@@ -230,7 +223,8 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s", server.Addr)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Error(err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestArtifactFlow(t *testing.T) {
|
func TestArtifactFlow(t *testing.T) {
|
||||||
artifactPath := t.TempDir()
|
artifactPath := t.TempDir()
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
uploads(router, artifactPath)
|
uploads(router, artifactPath)
|
||||||
downloads(router, artifactPath)
|
downloads(router, artifactPath)
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDryrunContext(t *testing.T) {
|
func TestDryrunContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
if Dryrun(ctx) {
|
if Dryrun(ctx) {
|
||||||
t.Fatal("plain context should not be dryrun")
|
t.Fatal("plain context should not be dryrun")
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ func TestDryrunContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJobErrorContainer(t *testing.T) {
|
func TestJobErrorContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
err := errors.New("job failed")
|
err := errors.New("job failed")
|
||||||
|
|
||||||
SetJobError(ctx, err)
|
SetJobError(ctx, err)
|
||||||
@@ -40,34 +40,27 @@ func TestJobErrorContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoggerAndHookContext(t *testing.T) {
|
func TestLoggerAndHandlerContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
if Logger(ctx) != logrus.StandardLogger() {
|
if Logger(ctx) != logging.Default() {
|
||||||
t.Fatal("plain context should use standard logger")
|
t.Fatal("plain context should use the default logger")
|
||||||
}
|
}
|
||||||
if LoggerHook(ctx) != nil {
|
if LoggerHandler(ctx) != nil {
|
||||||
t.Fatal("plain context should not have a logger hook")
|
t.Fatal("plain context should not have a logger handler")
|
||||||
|
}
|
||||||
|
if StepIDs(ctx) != nil {
|
||||||
|
t.Fatal("plain context should not have step IDs")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := logrus.New()
|
logger := logging.New(logtest.New())
|
||||||
ctx = WithLogger(ctx, logger)
|
ctx = WithLogger(ctx, logger)
|
||||||
if Logger(ctx) != logger {
|
if Logger(ctx) != logger {
|
||||||
t.Fatal("WithLogger should set logger")
|
t.Fatal("WithLogger should set logger")
|
||||||
}
|
}
|
||||||
|
|
||||||
hook := testHook{}
|
handler := logtest.New()
|
||||||
ctx = WithLoggerHook(ctx, hook)
|
ctx = WithLoggerHandler(ctx, handler)
|
||||||
if LoggerHook(ctx) != hook {
|
if LoggerHandler(ctx) != handler {
|
||||||
t.Fatal("WithLoggerHook should set hook")
|
t.Fatal("WithLoggerHandler should set handler")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type testHook struct{}
|
|
||||||
|
|
||||||
func (testHook) Levels() []logrus.Level {
|
|
||||||
return logrus.AllLevels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (testHook) Fire(*logrus.Entry) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Executor define contract for the steps of a workflow
|
// Executor define contract for the steps of a workflow
|
||||||
@@ -74,33 +74,33 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
errs := make(chan error, len(executors))
|
errs := make(chan error, len(executors))
|
||||||
|
|
||||||
if 1 > parallel {
|
if 1 > parallel {
|
||||||
log.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
logging.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
||||||
parallel = 1
|
parallel = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
||||||
|
|
||||||
for i := 0; i < parallel; i++ {
|
for i := 0; i < parallel; i++ {
|
||||||
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
||||||
log.Debugf("Worker %d started", workerID)
|
logging.Debugf("Worker %d started", workerID)
|
||||||
taskCount := 0
|
taskCount := 0
|
||||||
for executor := range work {
|
for executor := range work {
|
||||||
taskCount++
|
taskCount++
|
||||||
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
logging.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||||
// Recover from panics in executors to avoid crashing the worker
|
// Recover from panics in executors to avoid crashing the worker
|
||||||
// goroutine which would leave the runner process hung.
|
// goroutine which would leave the runner process hung.
|
||||||
// https://gitea.com/gitea/runner/issues/371
|
// https://gitea.com/gitea/runner/issues/371
|
||||||
errs <- func() (err error) {
|
errs <- func() (err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
logging.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return executor(ctx)
|
return executor(ctx)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
log.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
||||||
}(i, work, errs)
|
}(i, work, errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
func TestNewWorkflow(t *testing.T) {
|
func TestNewWorkflow(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
// empty
|
// empty
|
||||||
emptyWorkflow := NewPipelineExecutor()
|
emptyWorkflow := NewPipelineExecutor()
|
||||||
@@ -72,7 +72,7 @@ func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutor(t *testing.T) {
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
exec, count, maxActive := concurrencyProbe(2)
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
@@ -89,10 +89,10 @@ func TestNewParallelExecutor(t *testing.T) {
|
|||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
require.NoError(t, NewParallelExecutor(2)(ctx))
|
require.NoError(t, NewParallelExecutor(2)(ctx))
|
||||||
|
|
||||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
canceledCtx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := NewParallelExecutor(2)(canceledCtx)
|
err := NewParallelExecutor(2)(canceledCtx)
|
||||||
@@ -102,7 +102,7 @@ func TestNewParallelExecutorEmpty(t *testing.T) {
|
|||||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
@@ -118,7 +118,7 @@ func TestNewParallelExecutorFailed(t *testing.T) {
|
|||||||
func TestNewParallelExecutorCanceled(t *testing.T) {
|
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
errExpected := errors.New("fake error")
|
errExpected := errors.New("fake error")
|
||||||
@@ -150,12 +150,12 @@ func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
require.Error(t, NewParallelExecutor(2, executors...)(t.Context()))
|
||||||
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
var calls []string
|
var calls []string
|
||||||
record := func(name string) Executor {
|
record := func(name string) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -180,7 +180,7 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
|||||||
mainErr := errors.New("main failed")
|
mainErr := errors.New("main failed")
|
||||||
finalErr := errors.New("cleanup failed")
|
finalErr := errors.New("cleanup failed")
|
||||||
|
|
||||||
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/runner backs off
|
||||||
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
|
return min(max(time.Duration(seconds)*time.Second, 10*time.Second), 10*time.Minute)
|
||||||
|
}
|
||||||
|
return time.Duration(10+rand.IntN(21)) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) { // input.Dir is one clone URL and ref's, holding a directory per commit
|
||||||
|
unlock, err := AcquireCloneLock(ctx, input.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
if err := os.MkdirAll(input.Dir, 0o755); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := func(sha string) string { return filepath.Join(input.Dir, sha) }
|
||||||
|
record := filepath.Join(input.Dir, "ref")
|
||||||
|
data, _ := os.ReadFile(record)
|
||||||
|
sha, etag, _ := strings.Cut(string(data), "\n")
|
||||||
|
if !isFullObjectID(sha) || !exists(tree(sha)) {
|
||||||
|
sha, etag = "", ""
|
||||||
|
} else if input.OfflineMode || isFullObjectID(input.Ref) {
|
||||||
|
return tree(sha), sha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newSha, newEtag, err := downloadArchive(ctx, input, sha, etag)
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
common.Logger(ctx).Debugf("Cloning %s@%s: %v", input.URL, input.Ref, err)
|
||||||
|
newEtag = ""
|
||||||
|
newSha, err = exportClone(ctx, input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if newSha != sha || newEtag != etag {
|
||||||
|
if err := os.WriteFile(record, []byte(newSha+"\n"+newEtag), 0o600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree(newSha), newSha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadArchive(ctx context.Context, input NewGitCloneExecutorInput, sha, etag string) (string, string, error) {
|
||||||
|
rewritten, err := gitOutput(ctx, "", "ls-remote", "--get-url", input.URL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
source, err := url.Parse(rewritten)
|
||||||
|
if err != nil || source.Scheme != "http" && source.Scheme != "https" || hexRefRegex.MatchString(input.Ref) && !isFullObjectID(input.Ref) { // only git tells a short commit from a branch
|
||||||
|
return "", "", errors.New("no archive")
|
||||||
|
}
|
||||||
|
archive := source.JoinPath("archive", input.Ref+".tar.gz")
|
||||||
|
if source.Host == "github.com" {
|
||||||
|
source.Host = "codeload.github.com"
|
||||||
|
archive = source.JoinPath("tar.gz", input.Ref)
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: input.Transport, Timeout: 5 * time.Minute, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
if request.URL.Host != via[0].URL.Host || request.URL.Scheme != via[0].URL.Scheme { // the token stays on its origin
|
||||||
|
request.Header.Del("Authorization")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
newSha, newEtag, retryAfter, err := downloadArchiveOnce(ctx, client, archive.String(), input.Token, sha, etag, input.Dir)
|
||||||
|
if retryAfter == 0 || attempt == 3 || ctx.Err() != nil {
|
||||||
|
return newSha, newEtag, err
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Warnf("Failed to download action '%s'. Error: %v. Back off %s before retry.", archive.Redacted(), err, retryAfter)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", "", ctx.Err()
|
||||||
|
case <-time.After(retryAfter):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadArchiveOnce(ctx context.Context, client *http.Client, archive, token, sha, etag, dir string) (string, string, time.Duration, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, archive, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, err
|
||||||
|
}
|
||||||
|
if etag != "" {
|
||||||
|
request.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
request.SetBasicAuth("token", token)
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) && opErr.Op == "dial" {
|
||||||
|
return "", "", 0, err
|
||||||
|
}
|
||||||
|
return "", "", archiveRetryDelay(""), err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
switch status := response.StatusCode; {
|
||||||
|
case status == http.StatusNotModified && sha != "":
|
||||||
|
return sha, etag, 0, nil
|
||||||
|
case status == http.StatusOK:
|
||||||
|
newEtag := response.Header.Get("ETag")
|
||||||
|
gz, err := gzip.NewReader(response.Body)
|
||||||
|
if err == nil {
|
||||||
|
sha, err = extractTree(gz, dir, !strings.HasSuffix(newEtag, `-false"`)) // Gitea's mark of an unprefixed archive
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {
|
||||||
|
return "", "", archiveRetryDelay(""), err
|
||||||
|
}
|
||||||
|
return sha, newEtag, 0, err
|
||||||
|
case status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= http.StatusInternalServerError:
|
||||||
|
return "", "", archiveRetryDelay(response.Header.Get("Retry-After")), errors.New(response.Status)
|
||||||
|
default:
|
||||||
|
return "", "", 0, errors.New(response.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportClone(ctx context.Context, input NewGitCloneExecutorInput) (string, error) {
|
||||||
|
clone := input
|
||||||
|
clone.Dir = filepath.Join(input.Dir, "git")
|
||||||
|
sha, _, err := resolveClone(ctx, clone)
|
||||||
|
if err != nil || exists(filepath.Join(input.Dir, sha)) {
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
archive, err := runGit(ctx, clone.Dir, nil, "archive", "--format=tar", sha)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = extractTree(bytes.NewReader(archive), input.Dir, false)
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTree(archive io.Reader, dir string, strip bool) (string, error) { // into dir/<commit of the pax header>, strip dropping the directory wrapping the files
|
||||||
|
temp, err := os.MkdirTemp(dir, "tmp-")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(temp)
|
||||||
|
root, err := os.OpenRoot(temp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
sha := ""
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if header.Typeflag == tar.TypeXGlobalHeader {
|
||||||
|
if sha = header.PAXRecords["comment"]; isFullObjectID(sha) && exists(filepath.Join(dir, sha)) {
|
||||||
|
return sha, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := header.Name
|
||||||
|
if strip {
|
||||||
|
_, name, _ = strings.Cut(name, "/")
|
||||||
|
}
|
||||||
|
if name = strings.TrimSuffix(name, "/"); name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
err = root.MkdirAll(name, 0o755)
|
||||||
|
case tar.TypeReg:
|
||||||
|
err = writeTreeFile(root, name, header.Mode, reader)
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
if err = root.Symlink(header.Linkname, name); err != nil && runtime.GOOS == "windows" {
|
||||||
|
err = writeTreeFile(root, name, 0, strings.NewReader(header.Linkname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isFullObjectID(sha) {
|
||||||
|
return "", errors.New("the archive names no commit")
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sha, os.Rename(temp, filepath.Join(dir, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTreeFile(root *os.Root, name string, mode int64, content io.Reader) error {
|
||||||
|
perm := os.FileMode(0o644)
|
||||||
|
if mode&0o111 != 0 {
|
||||||
|
perm = 0o755
|
||||||
|
}
|
||||||
|
file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(file, content)
|
||||||
|
return errors.Join(err, file.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(name string) bool {
|
||||||
|
_, err := os.Stat(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cgi"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAction(t *testing.T) {
|
||||||
|
original := archiveRetryDelay
|
||||||
|
archiveRetryDelay = func(_retryAfter string) time.Duration { return time.Nanosecond }
|
||||||
|
t.Cleanup(func() { archiveRetryDelay = original })
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
remote := filepath.Join(root, "org", "action")
|
||||||
|
require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "add", "."))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
|
||||||
|
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
require.NoError(t, err)
|
||||||
|
backend := &cgi.Handler{Path: gitPath, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var archiveRequests []http.Header
|
||||||
|
var mirror *httptest.Server
|
||||||
|
requests, archives, failures := 0, "", 0
|
||||||
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
requests++
|
||||||
|
ref, ok := strings.CutPrefix(request.URL.Path, "/org/action/archive/")
|
||||||
|
if !ok {
|
||||||
|
backend.ServeHTTP(writer, request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archiveRequests = append(archiveRequests, request.Header.Clone())
|
||||||
|
switch {
|
||||||
|
case failures > 0:
|
||||||
|
failures--
|
||||||
|
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
case archives == "none":
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
return
|
||||||
|
case archives == "unreachable":
|
||||||
|
http.Redirect(writer, request, "http://127.0.0.1:1"+request.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
case archives == "redirect" && request.Host != mirror.Listener.Addr().String():
|
||||||
|
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
||||||
|
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
||||||
|
if request.Header.Get("If-None-Match") == etag {
|
||||||
|
writer.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{"archive", "--format=tar.gz", sha}
|
||||||
|
if archives != "unprefixed" {
|
||||||
|
args = append(args, "--prefix=action/")
|
||||||
|
}
|
||||||
|
archive, _ := runGit(request.Context(), remote, nil, args...)
|
||||||
|
writer.Header().Set("ETag", etag)
|
||||||
|
_, _ = writer.Write(archive)
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
mirror = httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
t.Cleanup(mirror.Close)
|
||||||
|
serve := func(mode string, failing int) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
requests, archives, failures, archiveRequests = 0, mode, failing, nil
|
||||||
|
}
|
||||||
|
served := func() (int, []http.Header) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return requests, append([]http.Header(nil), archiveRequests...)
|
||||||
|
}
|
||||||
|
newInput := func(ref string) NewGitCloneExecutorInput {
|
||||||
|
return NewGitCloneExecutorInput{URL: server.URL + "/org/action", Ref: ref, Dir: t.TempDir(), Token: "secret"}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("downloads, revalidates and follows a moved tag", func(t *testing.T) {
|
||||||
|
serve("prefixed", 0)
|
||||||
|
input := newInput("v1")
|
||||||
|
tree, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "v1"), sha)
|
||||||
|
assert.Equal(t, filepath.Join(input.Dir, sha), tree)
|
||||||
|
assert.NoDirExists(t, filepath.Join(tree, ".git"))
|
||||||
|
info, err := os.Stat(filepath.Join(tree, "run.sh"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||||
|
link, err := os.Readlink(filepath.Join(tree, "link"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "action.yml", link)
|
||||||
|
|
||||||
|
again, _, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tree, again)
|
||||||
|
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
||||||
|
moved, movedSha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "v1"), movedSha)
|
||||||
|
assert.NotEqual(t, tree, moved)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
||||||
|
|
||||||
|
input.OfflineMode = true
|
||||||
|
_, offlineSha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, movedSha, offlineSha)
|
||||||
|
|
||||||
|
requests, headers := served()
|
||||||
|
assert.Equal(t, 3, requests)
|
||||||
|
require.Len(t, headers, 3)
|
||||||
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), headers[0].Get("Authorization"))
|
||||||
|
assert.Empty(t, headers[0].Get("If-None-Match"))
|
||||||
|
assert.Equal(t, fmt.Sprintf(`W/"%s-tar.gz-true"`, sha), headers[1].Get("If-None-Match"))
|
||||||
|
assert.Equal(t, headers[1].Get("If-None-Match"), headers[2].Get("If-None-Match"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("downloads a pinned commit once and rejects a short one", func(t *testing.T) {
|
||||||
|
serve("prefixed", 0)
|
||||||
|
sha := gitRevParse(t, remote, "main")
|
||||||
|
input := newInput(sha)
|
||||||
|
for range 2 {
|
||||||
|
tree, got, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sha, got)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
||||||
|
}
|
||||||
|
_, _, err := FetchAction(t.Context(), newInput(sha[:7]))
|
||||||
|
require.ErrorIs(t, err, ErrShortRef)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retries a failing download and drops the token on a redirect to another origin", func(t *testing.T) {
|
||||||
|
serve("redirect", 2)
|
||||||
|
input := newInput("main")
|
||||||
|
_, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
_, headers := served()
|
||||||
|
require.Len(t, headers, 4)
|
||||||
|
assert.NotEmpty(t, headers[2].Get("Authorization"))
|
||||||
|
assert.Empty(t, headers[3].Get("Authorization"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps an unprefixed archive whole, downloads where insteadOf points and exports a clone without an archive or from an unreachable host", func(t *testing.T) {
|
||||||
|
for _, mode := range []string{"unprefixed", "none", "unreachable"} {
|
||||||
|
serve(mode, 0)
|
||||||
|
tree, sha, err := FetchAction(t.Context(), newInput("main"))
|
||||||
|
require.NoError(t, err, mode)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha, mode)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"), mode)
|
||||||
|
assert.NoDirExists(t, filepath.Join(tree, ".git"), mode)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
serve("prefixed", 0)
|
||||||
|
config := filepath.Join(t.TempDir(), "gitconfig")
|
||||||
|
require.NoError(t, os.WriteFile(config, []byte("[url \""+server.URL+"/org/\"]\n\tinsteadOf = https://example.invalid/org/\n"), 0o644))
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", config)
|
||||||
|
input := newInput("main")
|
||||||
|
input.URL = "https://example.invalid/org/action"
|
||||||
|
_, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTreeConfinesEntries(t *testing.T) {
|
||||||
|
outside := t.TempDir()
|
||||||
|
for name, entries := range map[string][]*tar.Header{
|
||||||
|
"parent path": {{Name: "../escape", Typeflag: tar.TypeReg}},
|
||||||
|
"through a symlink": {{Name: "out", Typeflag: tar.TypeSymlink, Linkname: outside}, {Name: "out/escape", Typeflag: tar.TypeReg}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": strings.Repeat("a", 40)}}))
|
||||||
|
for _, entry := range entries {
|
||||||
|
require.NoError(t, writer.WriteHeader(entry))
|
||||||
|
}
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := extractTree(&archive, dir, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NoFileExists(t, filepath.Join(dir, "escape"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(outside, "escape"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+302
-402
File diff suppressed because it is too large.
Load diff
+197
-236
File diff suppressed because it is too large.
Load diff
+29
-22
@@ -7,46 +7,53 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loggerContextKey string
|
type loggerContextKey string
|
||||||
|
|
||||||
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
const loggerContextKeyVal = loggerContextKey("logging.Logger")
|
||||||
|
|
||||||
// Logger returns the appropriate logger for current context
|
// Logger returns the appropriate logger for current context
|
||||||
func Logger(ctx context.Context) logrus.FieldLogger {
|
func Logger(ctx context.Context) *logging.Logger {
|
||||||
val := ctx.Value(loggerContextKeyVal)
|
if logger, ok := ctx.Value(loggerContextKeyVal).(*logging.Logger); ok {
|
||||||
if val != nil {
|
|
||||||
if logger, ok := val.(logrus.FieldLogger); ok {
|
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
}
|
return logging.Default()
|
||||||
return logrus.StandardLogger()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLogger adds a value to the context for the logger
|
// WithLogger adds a value to the context for the logger
|
||||||
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
|
func WithLogger(ctx context.Context, logger *logging.Logger) context.Context {
|
||||||
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggerHookKey string
|
type loggerHandlerKey string
|
||||||
|
|
||||||
const loggerHookKeyVal = loggerHookKey("logrus.Hook")
|
const loggerHandlerKeyVal = loggerHandlerKey("logging.Handler")
|
||||||
|
|
||||||
// LoggerHook returns the appropriate logger hook for current context
|
// LoggerHandler returns the extra handler the job logger sends its records to, or nil.
|
||||||
// the hook affects job logger, not global logger
|
// It affects the job logger, not the global logger.
|
||||||
func LoggerHook(ctx context.Context) logrus.Hook {
|
func LoggerHandler(ctx context.Context) logging.Handler {
|
||||||
val := ctx.Value(loggerHookKeyVal)
|
if h, ok := ctx.Value(loggerHandlerKeyVal).(logging.Handler); ok {
|
||||||
if val != nil {
|
return h
|
||||||
if hook, ok := val.(logrus.Hook); ok {
|
|
||||||
return hook
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLoggerHook adds a value to the context for the logger hook
|
// WithLoggerHandler adds the extra handler for the job logger to the context
|
||||||
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
func WithLoggerHandler(ctx context.Context, h logging.Handler) context.Context {
|
||||||
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
return context.WithValue(ctx, loggerHandlerKeyVal, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepIDsContextKey struct{}
|
||||||
|
|
||||||
|
// StepIDs returns the IDs of the step and the composite steps enclosing the current one, outermost first.
|
||||||
|
func StepIDs(ctx context.Context) []string {
|
||||||
|
ids, _ := ctx.Value(stepIDsContextKey{}).([]string)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStepIDs replaces the step IDs in the context
|
||||||
|
func WithStepIDs(ctx context.Context, ids []string) context.Context {
|
||||||
|
return context.WithValue(ctx, stepIDsContextKey{}, ids)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ansiReset = "\x1b[0m"
|
||||||
|
ansiRed = "\x1b[31m"
|
||||||
|
ansiYellow = "\x1b[33m"
|
||||||
|
ansiCyan = "\x1b[36m"
|
||||||
|
ansiDimCyan = "\x1b[2;36m"
|
||||||
|
ansiDimWhite = "\x1b[2;37m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleStart is the zero of the elapsed-seconds stamp used when full timestamps are off.
|
||||||
|
var consoleStart = time.Now()
|
||||||
|
|
||||||
|
// console is the state shared by every consoleHandler derived from one another.
|
||||||
|
type console struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
out io.Writer
|
||||||
|
terminal bool
|
||||||
|
opts TextOptions
|
||||||
|
reportCaller bool
|
||||||
|
level slog.LevelVar
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConsole(out io.Writer) *console {
|
||||||
|
c := &console{}
|
||||||
|
c.setOutput(out)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *console) setOutput(w io.Writer) {
|
||||||
|
terminal := checkIfTerminal(w)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.out, c.terminal = w, terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleHandler is a slog.Handler writing the text format the runner has always used, so that
|
||||||
|
// moving off logrus changes nothing a user can see: sorted key=value fields on a plain stream,
|
||||||
|
// and a colored prefix with elapsed or full time on a terminal.
|
||||||
|
type consoleHandler struct {
|
||||||
|
c *console
|
||||||
|
attrs []slog.Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||||
|
return level >= h.c.level.Level()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
return &consoleHandler{c: h.c, attrs: append(slices.Clone(h.attrs), attrs...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGroup is a no-op, as only NewSlogHandler wraps a consoleHandler and it never groups.
|
||||||
|
func (h *consoleHandler) WithGroup(string) slog.Handler { return h }
|
||||||
|
|
||||||
|
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||||
|
c := h.c
|
||||||
|
c.mu.Lock()
|
||||||
|
opts, reportCaller, terminal := c.opts, c.reportCaller, c.terminal
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
data := make(map[string]any, len(h.attrs)+r.NumAttrs())
|
||||||
|
for _, a := range h.attrs {
|
||||||
|
data[a.Key] = a.Value.Resolve().Any()
|
||||||
|
}
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
data[a.Key] = a.Value.Resolve().Any()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
var caller *runtime.Frame
|
||||||
|
if reportCaller && r.PC != 0 {
|
||||||
|
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
|
||||||
|
caller = &frame
|
||||||
|
}
|
||||||
|
prefixFieldClashes(data, caller != nil)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(data))
|
||||||
|
for k := range data {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if terminal && !opts.DisableColors {
|
||||||
|
printColored(&b, opts, r, caller, keys, data)
|
||||||
|
} else {
|
||||||
|
printPlain(&b, opts, r, caller, keys, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := b.Bytes()
|
||||||
|
if opts.Filter != nil {
|
||||||
|
line = opts.Filter(line)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
_, err := c.out.Write(line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixFieldClashes moves fields that share a name with a fixed key out of its way.
|
||||||
|
func prefixFieldClashes(data map[string]any, reportCaller bool) {
|
||||||
|
for _, key := range []string{"time", "level", "msg"} {
|
||||||
|
if v, ok := data[key]; ok {
|
||||||
|
data["fields."+key] = v
|
||||||
|
delete(data, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reportCaller {
|
||||||
|
for _, key := range []string{"func", "file"} {
|
||||||
|
if v, ok := data[key]; ok {
|
||||||
|
data["fields."+key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlain(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
|
||||||
|
if !opts.DisableTimestamp {
|
||||||
|
appendKeyValue(b, "time", r.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
appendKeyValue(b, "level", plainLevel(Level(r.Level)))
|
||||||
|
if r.Message != "" {
|
||||||
|
appendKeyValue(b, "msg", r.Message)
|
||||||
|
}
|
||||||
|
if caller != nil {
|
||||||
|
funcVal, fileVal := callerText(opts, caller)
|
||||||
|
if funcVal != "" {
|
||||||
|
appendKeyValue(b, "func", funcVal)
|
||||||
|
}
|
||||||
|
if fileVal != "" {
|
||||||
|
appendKeyValue(b, "file", fileVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
appendKeyValue(b, k, data[k])
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func callerText(opts TextOptions, caller *runtime.Frame) (funcVal, fileVal string) {
|
||||||
|
if opts.CallerPrettyfier != nil {
|
||||||
|
return opts.CallerPrettyfier(caller)
|
||||||
|
}
|
||||||
|
return caller.Function, caller.File + ":" + strconv.Itoa(caller.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printColored(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
|
||||||
|
msg := strings.TrimSuffix(r.Message, "\n")
|
||||||
|
|
||||||
|
var callerStr string
|
||||||
|
if caller != nil {
|
||||||
|
funcVal, fileVal := callerText(opts, caller)
|
||||||
|
if opts.CallerPrettyfier == nil && caller.Function != "" {
|
||||||
|
funcVal += "()"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case fileVal == "":
|
||||||
|
callerStr = funcVal
|
||||||
|
case funcVal == "":
|
||||||
|
callerStr = fileVal
|
||||||
|
default:
|
||||||
|
callerStr = fileVal + " " + funcVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
level := Level(r.Level)
|
||||||
|
prefix := colorize(level, strings.ToUpper(plainLevel(level))[:4])
|
||||||
|
switch {
|
||||||
|
case opts.DisableTimestamp:
|
||||||
|
fmt.Fprintf(b, "%s%s %-44s ", prefix, callerStr, msg)
|
||||||
|
case !opts.FullTimestamp:
|
||||||
|
fmt.Fprintf(b, "%s[%04d]%s %-44s ", prefix, int(r.Time.Sub(consoleStart)/time.Second), callerStr, msg)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(b, "%s[%s]%s %-44s ", prefix, r.Time.Format(time.RFC3339), callerStr, msg)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(colorize(level, k))
|
||||||
|
b.WriteByte('=')
|
||||||
|
appendValue(b, data[k])
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainLevel spells Warn the way logrus did, as "warning".
|
||||||
|
func plainLevel(l Level) string {
|
||||||
|
if s := l.String(); s != "warn" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorize(l Level, s string) string {
|
||||||
|
var color string
|
||||||
|
switch {
|
||||||
|
case l <= LevelTrace:
|
||||||
|
color = ansiDimWhite
|
||||||
|
case l <= LevelDebug:
|
||||||
|
color = ansiDimCyan
|
||||||
|
case l <= LevelInfo:
|
||||||
|
color = ansiCyan
|
||||||
|
case l <= LevelWarn:
|
||||||
|
color = ansiYellow
|
||||||
|
default:
|
||||||
|
color = ansiRed
|
||||||
|
}
|
||||||
|
return color + s + ansiReset
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendKeyValue(b *bytes.Buffer, key string, value any) {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(key)
|
||||||
|
b.WriteByte('=')
|
||||||
|
appendValue(b, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendValue(b *bytes.Buffer, value any) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
appendString(b, v)
|
||||||
|
case []byte:
|
||||||
|
appendString(b, string(v))
|
||||||
|
case error:
|
||||||
|
appendFormatted(b, v, v.Error)
|
||||||
|
case fmt.Stringer:
|
||||||
|
appendFormatted(b, v, v.String)
|
||||||
|
default:
|
||||||
|
appendString(b, fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendFormatted writes what get returns, and "<nil>" if that panics on a nil receiver.
|
||||||
|
func appendFormatted(b *bytes.Buffer, v any, get func() string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() {
|
||||||
|
appendString(b, "<nil>")
|
||||||
|
} else {
|
||||||
|
appendString(b, fmt.Sprintf("%%!v(PANIC=%v)", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
appendString(b, get())
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendString(b *bytes.Buffer, s string) {
|
||||||
|
if needsQuoting(s) {
|
||||||
|
b.WriteString(strconv.Quote(s))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQuoting(s string) bool {
|
||||||
|
for i := range len(s) {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||||
|
case c == '-', c == '.', c == '_', c == '/', c == '@', c == '^', c == '+':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func consoleLine(t *testing.T, opts TextOptions, terminal, reportCaller bool, build func(*Logger)) string {
|
||||||
|
t.Helper()
|
||||||
|
var out bytes.Buffer
|
||||||
|
c := newConsole(&out)
|
||||||
|
c.opts, c.terminal, c.reportCaller = opts, terminal, reportCaller
|
||||||
|
c.level.Set(slog.LevelDebug)
|
||||||
|
build(New(NewSlogHandler(&consoleHandler{c: c})))
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePlain(t *testing.T) {
|
||||||
|
line := consoleLine(t, TextOptions{DisableTimestamp: true}, false, false, func(l *Logger) {
|
||||||
|
l.With("job", "build x", "n", 3).WithError(errors.New("boom")).Warnf("hello world")
|
||||||
|
l.With("msg", "clash", "empty", "").Info("")
|
||||||
|
})
|
||||||
|
assert.Equal(t, "level=warning msg=\"hello world\" error=boom job=\"build x\" n=3\n"+
|
||||||
|
"level=info empty= fields.msg=clash\n", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePlainTimeFilterAndCaller(t *testing.T) {
|
||||||
|
opts := TextOptions{
|
||||||
|
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
|
||||||
|
return "[fn]", "[" + strings.TrimPrefix(f.Function, "x") + "]"
|
||||||
|
},
|
||||||
|
Filter: func(b []byte) []byte { return bytes.ReplaceAll(b, []byte("secret"), []byte("***")) },
|
||||||
|
}
|
||||||
|
line := consoleLine(t, opts, false, true, func(l *Logger) { l.With("k", "secret").Debugf("a secret") })
|
||||||
|
assert.Regexp(t, `^time="[^"]+" level=debug msg="a \*\*\*" func="\[fn\]" file="\[.*TestConsolePlainTimeFilterAndCaller[^"]*\]" k=\*\*\*\n$`, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleColored(t *testing.T) {
|
||||||
|
line := consoleLine(t, TextOptions{DisableTimestamp: true}, true, false, func(l *Logger) {
|
||||||
|
l.With("job", "j").Warn("careful")
|
||||||
|
})
|
||||||
|
assert.Equal(t, "\x1b[33mWARN\x1b[0m "+"careful"+strings.Repeat(" ", 44-len("careful"))+" \x1b[33mjob\x1b[0m=j\n", line)
|
||||||
|
|
||||||
|
full := consoleLine(t, TextOptions{FullTimestamp: true}, true, false, func(l *Logger) { l.Error("bad") })
|
||||||
|
assert.Regexp(t, `^\x1b\[31mERRO\x1b\[0m\[\d{4}-\d\d-\d\dT[^\]]+\] bad`, full)
|
||||||
|
|
||||||
|
elapsed := consoleLine(t, TextOptions{}, true, false, func(l *Logger) { l.Info("x") })
|
||||||
|
assert.Regexp(t, `^\x1b\[36mINFO\x1b\[0m\[\d{4}\] x`, elapsed)
|
||||||
|
|
||||||
|
plain := consoleLine(t, TextOptions{DisableColors: true, DisableTimestamp: true}, true, false, func(l *Logger) { l.Info("x") })
|
||||||
|
assert.Equal(t, "level=info msg=x\n", plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleLevelAndOutput(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
c := newConsole(&out)
|
||||||
|
h := &consoleHandler{c: c}
|
||||||
|
assert.False(t, h.Enabled(t.Context(), slog.LevelDebug)) // info unless raised
|
||||||
|
c.level.Set(slog.LevelError)
|
||||||
|
assert.False(t, h.Enabled(t.Context(), slog.LevelWarn))
|
||||||
|
assert.True(t, h.Enabled(t.Context(), slog.LevelError))
|
||||||
|
|
||||||
|
rec := slog.NewRecord(time.Unix(0, 0), slog.LevelError, "m", 0)
|
||||||
|
rec.AddAttrs(slog.String("c", "3"))
|
||||||
|
require.NoError(t, h.WithAttrs([]slog.Attr{slog.Int("a", 1)}).Handle(t.Context(), rec))
|
||||||
|
assert.Contains(t, out.String(), "a=1 c=3")
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextOptions describes how the process-wide Logger writes a record as text.
|
||||||
|
type TextOptions struct {
|
||||||
|
DisableColors bool
|
||||||
|
DisableTimestamp bool
|
||||||
|
FullTimestamp bool
|
||||||
|
// CallerPrettyfier renames the function and file reported with a record when ReportCaller is on.
|
||||||
|
CallerPrettyfier func(*runtime.Frame) (function, file string)
|
||||||
|
// Filter rewrites each formatted line, for example to mask secrets.
|
||||||
|
Filter func(line []byte) []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
std atomic.Pointer[Logger]
|
||||||
|
stdState = newConsole(os.Stderr)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
std.Store(New(NewSlogHandler(&consoleHandler{c: stdState})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the process-wide Logger.
|
||||||
|
func Default() *Logger { return std.Load() }
|
||||||
|
|
||||||
|
// SetDefault replaces the process-wide Logger, which tests use to capture what the package
|
||||||
|
// functions log. SetLevel, GetLevel, SetOutput and the format functions keep configuring the
|
||||||
|
// initial console Logger.
|
||||||
|
func SetDefault(l *Logger) { std.Store(l) }
|
||||||
|
|
||||||
|
// SetLevel sets the minimum level of the initial process-wide Logger.
|
||||||
|
func SetLevel(level Level) { stdState.level.Set(slog.Level(level)) }
|
||||||
|
|
||||||
|
// GetLevel returns the minimum level of the initial process-wide Logger.
|
||||||
|
func GetLevel() Level { return Level(stdState.level.Level()) }
|
||||||
|
|
||||||
|
// SetOutput sets where the initial process-wide Logger writes.
|
||||||
|
func SetOutput(w io.Writer) { stdState.setOutput(w) }
|
||||||
|
|
||||||
|
// Output returns where the initial process-wide Logger writes.
|
||||||
|
func Output() io.Writer {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
return stdState.out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTextFormat sets the text format of the initial process-wide Logger.
|
||||||
|
func SetTextFormat(o TextOptions) {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
stdState.opts = o
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReportCaller makes the initial process-wide Logger record where each call came from, which costs time.
|
||||||
|
func SetReportCaller(on bool) {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
stdState.reportCaller = on
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enabled(level Level) bool { return Default().Enabled(level) }
|
||||||
|
|
||||||
|
func With(args ...any) *Logger { return Default().With(args...) }
|
||||||
|
func WithError(err error) *Logger { return Default().WithError(err) }
|
||||||
|
func Tracef(format string, args ...any) { Default().logf(LevelTrace, format, args) }
|
||||||
|
func Debugf(format string, args ...any) { Default().logf(LevelDebug, format, args) }
|
||||||
|
func Infof(format string, args ...any) { Default().logf(LevelInfo, format, args) }
|
||||||
|
func Warnf(format string, args ...any) { Default().logf(LevelWarn, format, args) }
|
||||||
|
func Errorf(format string, args ...any) { Default().logf(LevelError, format, args) }
|
||||||
|
func Trace(args ...any) { Default().log(LevelTrace, args) }
|
||||||
|
func Debug(args ...any) { Default().log(LevelDebug, args) }
|
||||||
|
func Info(args ...any) { Default().log(LevelInfo, args) }
|
||||||
|
func Warn(args ...any) { Default().log(LevelWarn, args) }
|
||||||
|
func Error(args ...any) { Default().log(LevelError, args) }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attr is a structured key/value pair attached to a log record.
|
||||||
|
type Attr struct {
|
||||||
|
Key string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record is one log event. Attrs holds the call-site attributes only, the ones added with
|
||||||
|
// Logger.With are accumulated by the Handler.
|
||||||
|
type Record struct {
|
||||||
|
Time time.Time
|
||||||
|
Level Level
|
||||||
|
Message string
|
||||||
|
PC uintptr // program counter of the logging call, 0 if unknown
|
||||||
|
Attrs []Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attr returns the value of the last attribute named key.
|
||||||
|
func (r Record) Attr(key string) (any, bool) {
|
||||||
|
for _, a := range slices.Backward(r.Attrs) {
|
||||||
|
if a.Key == key {
|
||||||
|
return a.Value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler consumes records, and is shaped like slog.Handler so that a slog backend can replace
|
||||||
|
// the current one without touching callers. Handle must not modify the Attrs slice it is given.
|
||||||
|
type Handler interface {
|
||||||
|
Enabled(ctx context.Context, level Level) bool
|
||||||
|
Handle(ctx context.Context, r Record) error
|
||||||
|
// WithAttrs returns a Handler that adds attrs to every record it handles.
|
||||||
|
WithAttrs(attrs []Attr) Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiHandler []Handler
|
||||||
|
|
||||||
|
// NewMultiHandler fans every record out to all handlers that have its level enabled.
|
||||||
|
func NewMultiHandler(handlers ...Handler) Handler {
|
||||||
|
return multiHandler(handlers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
for _, h := range m {
|
||||||
|
if h.Enabled(ctx, level) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
var errs []error
|
||||||
|
for _, h := range m {
|
||||||
|
if h.Enabled(ctx, r.Level) {
|
||||||
|
errs = append(errs, h.Handle(ctx, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
out := make(multiHandler, len(m))
|
||||||
|
for i, h := range m {
|
||||||
|
out[i] = h.WithAttrs(attrs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type discardHandler struct{}
|
||||||
|
|
||||||
|
// DiscardHandler drops every record.
|
||||||
|
var DiscardHandler Handler = discardHandler{}
|
||||||
|
|
||||||
|
func (discardHandler) Enabled(context.Context, Level) bool { return false }
|
||||||
|
func (discardHandler) Handle(context.Context, Record) error { return nil }
|
||||||
|
func (d discardHandler) WithAttrs([]Attr) Handler { return d }
|
||||||
|
|
||||||
|
type levelHandler struct {
|
||||||
|
h Handler
|
||||||
|
minLevel Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelHandler passes only records at or above minLevel on to h.
|
||||||
|
func NewLevelHandler(h Handler, minLevel Level) Handler {
|
||||||
|
return levelHandler{h: h, minLevel: minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
return level >= l.minLevel && l.h.Enabled(ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
if r.Level < l.minLevel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.h.Handle(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
return levelHandler{h: l.h.WithAttrs(attrs), minLevel: l.minLevel}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a log severity. The values match log/slog, with Trace added below Debug.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelTrace Level = -8
|
||||||
|
LevelDebug Level = -4
|
||||||
|
LevelInfo Level = 0
|
||||||
|
LevelWarn Level = 4
|
||||||
|
LevelError Level = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch {
|
||||||
|
case l <= LevelTrace:
|
||||||
|
return "trace"
|
||||||
|
case l <= LevelDebug:
|
||||||
|
return "debug"
|
||||||
|
case l <= LevelInfo:
|
||||||
|
return "info"
|
||||||
|
case l <= LevelWarn:
|
||||||
|
return "warn"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel accepts every name logrus does. "fatal" and "panic" map to LevelError because
|
||||||
|
// the Logger has no level above it.
|
||||||
|
func ParseLevel(s string) (Level, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "trace":
|
||||||
|
return LevelTrace, nil
|
||||||
|
case "debug":
|
||||||
|
return LevelDebug, nil
|
||||||
|
case "info":
|
||||||
|
return LevelInfo, nil
|
||||||
|
case "warn", "warning":
|
||||||
|
return LevelWarn, nil
|
||||||
|
case "error", "fatal", "panic":
|
||||||
|
return LevelError, nil
|
||||||
|
}
|
||||||
|
return LevelInfo, fmt.Errorf("not a valid log level: %q", s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const badKey = "!BADKEY"
|
||||||
|
|
||||||
|
// Logger writes printf-style messages with structured attributes to a Handler.
|
||||||
|
type Logger struct {
|
||||||
|
h Handler
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(h Handler) *Logger {
|
||||||
|
return &Logger{h: h, ctx: context.Background()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With returns a Logger that adds the key/value pairs to every record, like slog.Logger.With.
|
||||||
|
func (l *Logger) With(args ...any) *Logger {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return &Logger{h: l.h.WithAttrs(argsToAttrs(args)), ctx: l.ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) WithError(err error) *Logger {
|
||||||
|
return l.With("error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext returns a Logger that passes ctx to its Handler.
|
||||||
|
func (l *Logger) WithContext(ctx context.Context) *Logger {
|
||||||
|
return &Logger{h: l.h, ctx: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Enabled(level Level) bool {
|
||||||
|
return l.h.Enabled(l.ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Tracef(format string, args ...any) { l.logf(LevelTrace, format, args) }
|
||||||
|
func (l *Logger) Debugf(format string, args ...any) { l.logf(LevelDebug, format, args) }
|
||||||
|
func (l *Logger) Infof(format string, args ...any) { l.logf(LevelInfo, format, args) }
|
||||||
|
func (l *Logger) Warnf(format string, args ...any) { l.logf(LevelWarn, format, args) }
|
||||||
|
func (l *Logger) Errorf(format string, args ...any) { l.logf(LevelError, format, args) }
|
||||||
|
|
||||||
|
// Trace and the methods below format their arguments like fmt.Sprint.
|
||||||
|
func (l *Logger) Trace(args ...any) { l.log(LevelTrace, args) }
|
||||||
|
func (l *Logger) Debug(args ...any) { l.log(LevelDebug, args) }
|
||||||
|
func (l *Logger) Info(args ...any) { l.log(LevelInfo, args) }
|
||||||
|
func (l *Logger) Warn(args ...any) { l.log(LevelWarn, args) }
|
||||||
|
func (l *Logger) Error(args ...any) { l.log(LevelError, args) }
|
||||||
|
|
||||||
|
func (l *Logger) logf(level Level, format string, args []any) {
|
||||||
|
if !l.Enabled(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.emit(callerPC(4), level, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(level Level, args []any) {
|
||||||
|
if !l.Enabled(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.emit(callerPC(4), level, fmt.Sprint(args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit drops the Handler error, as a logger has nowhere to report it.
|
||||||
|
func (l *Logger) emit(pc uintptr, level Level, msg string) {
|
||||||
|
_ = l.h.Handle(l.ctx, Record{Time: time.Now(), Level: level, Message: msg, PC: pc})
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerPC returns the program counter skip frames up, 4 being the caller of an exported method.
|
||||||
|
func callerPC(skip int) uintptr {
|
||||||
|
var pcs [1]uintptr
|
||||||
|
runtime.Callers(skip, pcs[:])
|
||||||
|
return pcs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func argsToAttrs(args []any) []Attr {
|
||||||
|
attrs := make([]Attr, 0, len(args)/2+1)
|
||||||
|
for len(args) > 0 {
|
||||||
|
switch k := args[0].(type) {
|
||||||
|
case Attr:
|
||||||
|
attrs = append(attrs, k)
|
||||||
|
args = args[1:]
|
||||||
|
case string:
|
||||||
|
if len(args) == 1 {
|
||||||
|
attrs = append(attrs, Attr{Key: badKey, Value: k})
|
||||||
|
args = nil
|
||||||
|
} else {
|
||||||
|
attrs = append(attrs, Attr{Key: k, Value: args[1]})
|
||||||
|
args = args[2:]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
attrs = append(attrs, Attr{Key: badKey, Value: k})
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLevel(t *testing.T) {
|
||||||
|
for in, want := range map[string]logging.Level{
|
||||||
|
"trace": logging.LevelTrace, "DEBUG": logging.LevelDebug, "info": logging.LevelInfo,
|
||||||
|
"warn": logging.LevelWarn, "warning": logging.LevelWarn,
|
||||||
|
"error": logging.LevelError, "fatal": logging.LevelError, "panic": logging.LevelError,
|
||||||
|
} {
|
||||||
|
got, err := logging.ParseLevel(in)
|
||||||
|
require.NoError(t, err, in)
|
||||||
|
assert.Equal(t, want, got, in)
|
||||||
|
}
|
||||||
|
_, err := logging.ParseLevel("loud")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, "warn", logging.LevelWarn.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingStringer struct{ n *int }
|
||||||
|
|
||||||
|
func (c countingStringer) String() string { *c.n++; return "x" }
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
h := logtest.NewWithLevel(logging.LevelTrace)
|
||||||
|
l := logging.New(h)
|
||||||
|
|
||||||
|
l.With("job", "j1").WithError(errors.New("boom")).Warnf("step %d", 2)
|
||||||
|
l.Info("a", 1, 2, "b")
|
||||||
|
l.With("k").With(7).Debug("x")
|
||||||
|
|
||||||
|
recs := h.AllRecords()
|
||||||
|
require.Len(t, recs, 3)
|
||||||
|
assert.Equal(t, "step 2", recs[0].Message)
|
||||||
|
assert.Equal(t, logging.LevelWarn, recs[0].Level)
|
||||||
|
assert.False(t, recs[0].Time.IsZero())
|
||||||
|
job, _ := recs[0].Attr("job")
|
||||||
|
assert.Equal(t, "j1", job)
|
||||||
|
errv, _ := recs[0].Attr("error")
|
||||||
|
require.EqualError(t, errv.(error), "boom")
|
||||||
|
assert.Equal(t, "a1 2b", recs[1].Message) // fmt.Sprint spacing
|
||||||
|
bad, _ := recs[2].Attr("!BADKEY")
|
||||||
|
assert.Equal(t, 7, bad)
|
||||||
|
|
||||||
|
h.Reset()
|
||||||
|
assert.Nil(t, h.LastRecord())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerSkipsFormattingWhenDisabled(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
l := logging.New(logging.NewLevelHandler(logtest.NewWithLevel(logging.LevelTrace), logging.LevelWarn))
|
||||||
|
l.Debugf("%v", countingStringer{&n})
|
||||||
|
assert.Zero(t, n)
|
||||||
|
assert.False(t, l.Enabled(logging.LevelInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiHandler(t *testing.T) {
|
||||||
|
assert.False(t, logging.DiscardHandler.Enabled(t.Context(), logging.LevelError))
|
||||||
|
all, warnOnly := logtest.New(), logtest.NewWithLevel(logging.LevelTrace)
|
||||||
|
l := logging.New(logging.NewMultiHandler(all, logging.NewLevelHandler(warnOnly, logging.LevelWarn), logging.DiscardHandler)).With("a", 1)
|
||||||
|
l.Info("i")
|
||||||
|
l.Error("e")
|
||||||
|
assert.Len(t, all.AllRecords(), 2)
|
||||||
|
require.Len(t, warnOnly.AllRecords(), 1)
|
||||||
|
v, _ := warnOnly.LastRecord().Attr("a")
|
||||||
|
assert.Equal(t, 1, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameOf(t *testing.T, pc uintptr) runtime.Frame {
|
||||||
|
t.Helper()
|
||||||
|
require.NotZero(t, pc)
|
||||||
|
f, _ := runtime.CallersFrames([]uintptr{pc}).Next()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerPC(t *testing.T) {
|
||||||
|
l, h := logtest.NewLogger()
|
||||||
|
l.Infof("method")
|
||||||
|
l.Info("method")
|
||||||
|
|
||||||
|
prev := logging.Default()
|
||||||
|
t.Cleanup(func() { logging.SetDefault(prev) })
|
||||||
|
logging.SetDefault(l)
|
||||||
|
logging.Infof("global")
|
||||||
|
logging.Error("global")
|
||||||
|
|
||||||
|
require.Len(t, h.AllRecords(), 4)
|
||||||
|
for _, r := range h.AllRecords() {
|
||||||
|
assert.True(t, strings.HasSuffix(frameOf(t, r.PC).Function, "TestCallerPC"), r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLevel(t *testing.T) {
|
||||||
|
prev := logging.GetLevel()
|
||||||
|
t.Cleanup(func() { logging.SetLevel(prev) })
|
||||||
|
|
||||||
|
logging.SetLevel(logging.LevelWarn)
|
||||||
|
assert.Equal(t, logging.LevelWarn, logging.GetLevel())
|
||||||
|
assert.True(t, logging.Enabled(logging.LevelError))
|
||||||
|
assert.False(t, logging.Enabled(logging.LevelInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogHandler(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
slogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
l := logging.New(logging.NewSlogHandler(slogger.Handler())).With("job", "j1")
|
||||||
|
|
||||||
|
l.Tracef("dropped")
|
||||||
|
assert.False(t, l.Enabled(logging.LevelTrace))
|
||||||
|
l.WithError(errors.New("boom")).Errorf("fail %s", "x")
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
assert.Contains(t, out, "level=ERROR")
|
||||||
|
assert.Contains(t, out, `msg="fail x"`)
|
||||||
|
assert.Contains(t, out, "job=j1")
|
||||||
|
assert.Contains(t, out, "error=boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogBridge(t *testing.T) {
|
||||||
|
h := logtest.SetDefault(t)
|
||||||
|
slog.With("lib", "actionslib").Warn("from a library", "n", 1)
|
||||||
|
_, _, line, _ := runtime.Caller(0)
|
||||||
|
slog.Debug("dropped")
|
||||||
|
|
||||||
|
recs := h.AllRecords()
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "from a library", recs[0].Message)
|
||||||
|
assert.Equal(t, logging.LevelWarn, recs[0].Level)
|
||||||
|
lib, _ := recs[0].Attr("lib")
|
||||||
|
assert.Equal(t, "actionslib", lib)
|
||||||
|
n, _ := recs[0].Attr("n")
|
||||||
|
assert.EqualValues(t, 1, n)
|
||||||
|
assert.Equal(t, line-1, frameOf(t, recs[0].PC).Line)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package logtest provides a Handler that keeps records in memory for assertions.
|
||||||
|
package logtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []logging.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler records what it receives at or above its level. Loggers derived with WithAttrs share one store.
|
||||||
|
type Handler struct {
|
||||||
|
store *store
|
||||||
|
attrs []logging.Attr
|
||||||
|
minLevel logging.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// New records from LevelInfo up, like the logger of logrus's hooks/test.
|
||||||
|
func New() *Handler {
|
||||||
|
return NewWithLevel(logging.LevelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger returns a Logger writing to a new Handler from New, and that Handler.
|
||||||
|
func NewLogger() (*logging.Logger, *Handler) {
|
||||||
|
h := New()
|
||||||
|
return logging.New(h), h
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithLevel(minLevel logging.Level) *Handler {
|
||||||
|
return &Handler{store: &store{}, minLevel: minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Enabled(_ context.Context, level logging.Level) bool { return level >= h.minLevel }
|
||||||
|
|
||||||
|
// Handle stores the record with the attributes accumulated by WithAttrs placed first.
|
||||||
|
func (h *Handler) Handle(_ context.Context, r logging.Record) error {
|
||||||
|
r.Attrs = append(slices.Clone(h.attrs), r.Attrs...)
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
h.store.records = append(h.store.records, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
||||||
|
return &Handler{store: h.store, attrs: append(slices.Clone(h.attrs), attrs...), minLevel: h.minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AllRecords() []logging.Record {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
return slices.Clone(h.store.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRecord returns nil when nothing was logged.
|
||||||
|
func (h *Handler) LastRecord() *logging.Record {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
if len(h.store.records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := h.store.records[len(h.store.records)-1]
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Reset() {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
h.store.records = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefault makes the process-wide Logger record into a new Handler until the test ends.
|
||||||
|
func SetDefault(t testing.TB) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
prev := logging.Default()
|
||||||
|
logger, h := NewLogger()
|
||||||
|
logging.SetDefault(logger)
|
||||||
|
t.Cleanup(func() { logging.SetDefault(prev) })
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
slog.SetDefault(slog.New(slogBridge{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// slogBridge hands what libraries log through slog's global logger to the process-wide Logger.
|
||||||
|
type slogBridge struct {
|
||||||
|
attrs []Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
return Default().h.Enabled(ctx, Level(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
h := Default().h
|
||||||
|
if len(b.attrs) > 0 {
|
||||||
|
h = h.WithAttrs(b.attrs)
|
||||||
|
}
|
||||||
|
attrs := make([]Attr, 0, r.NumAttrs())
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
attrs = append(attrs, fromSlogAttr(a))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return h.Handle(ctx, Record{Time: r.Time, Level: Level(r.Level), Message: r.Message, PC: r.PC, Attrs: attrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
out := slices.Clone(b.attrs)
|
||||||
|
for _, a := range attrs {
|
||||||
|
out = append(out, fromSlogAttr(a))
|
||||||
|
}
|
||||||
|
return slogBridge{attrs: out}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) WithGroup(string) slog.Handler { return b }
|
||||||
|
|
||||||
|
func fromSlogAttr(a slog.Attr) Attr {
|
||||||
|
return Attr{Key: a.Key, Value: a.Value.Resolve().Any()}
|
||||||
|
}
|
||||||
|
|
||||||
|
type slogHandler struct {
|
||||||
|
h slog.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSlogHandler writes to h. Levels map one to one, as Level uses slog's values.
|
||||||
|
func NewSlogHandler(h slog.Handler) Handler {
|
||||||
|
return slogHandler{h: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
return s.h.Enabled(ctx, slog.Level(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
rec := slog.NewRecord(r.Time, slog.Level(r.Level), r.Message, r.PC)
|
||||||
|
rec.AddAttrs(toSlogAttrs(r.Attrs)...)
|
||||||
|
return s.h.Handle(ctx, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
return slogHandler{h: s.h.WithAttrs(toSlogAttrs(attrs))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSlogAttrs(attrs []Attr) []slog.Attr {
|
||||||
|
out := make([]slog.Attr, len(attrs))
|
||||||
|
for i, a := range attrs {
|
||||||
|
out[i] = slog.Any(a.Key, a.Value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkIfTerminal(w io.Writer) bool {
|
||||||
|
if f, ok := w.(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkIfTerminal also turns on virtual terminal processing, which the console needs to show colors.
|
||||||
|
func checkIfTerminal(w io.Writer) bool {
|
||||||
|
f, ok := w.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
handle := windows.Handle(f.Fd())
|
||||||
|
var mode uint32
|
||||||
|
if err := windows.GetConsoleMode(handle, &mode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil
|
||||||
|
}
|
||||||
+244
-13
@@ -7,20 +7,33 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/cli/cli/config"
|
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/moby/moby/api/types/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
cfg, err := loadDockerConfig(ctx)
|
||||||
// file is malformed; use config.Load to route errors through the logger.
|
|
||||||
cfg, err := config.Load(config.Dir())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
@@ -34,32 +47,27 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authConfig, err := cfg.GetAuthConfig(registryKey)
|
authConfig, err := cfg.authConfig(ctx, registryKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return registry.AuthConfig(authConfig), nil
|
return authConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
cfg, err := config.Load(config.Dir())
|
cfg, err := loadDockerConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
creds, err := cfg.GetAllCredentials()
|
authConfigs, err := cfg.allAuthConfigs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
|
||||||
for k, v := range creds {
|
|
||||||
authConfigs[k] = registry.AuthConfig(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return authConfigs
|
return authConfigs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +77,226 @@ func registryAuthConfigKey(domainName string) string {
|
|||||||
}
|
}
|
||||||
return domainName
|
return domainName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dockerConfig struct {
|
||||||
|
Auths map[string]registry.AuthConfig `json:"auths"`
|
||||||
|
CredsStore string `json:"credsStore"`
|
||||||
|
CredHelpers map[string]string `json:"credHelpers"`
|
||||||
|
env map[string]registry.AuthConfig // from DOCKER_AUTH_CONFIG, nil when unset or unusable
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDockerConfig(ctx context.Context) (*dockerConfig, error) {
|
||||||
|
dir := os.Getenv("DOCKER_CONFIG")
|
||||||
|
if dir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" && runtime.GOOS != "windows" {
|
||||||
|
if current, err := user.Current(); err == nil {
|
||||||
|
home = current.HomeDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir = filepath.Join(home, ".docker")
|
||||||
|
}
|
||||||
|
cfg := &dockerConfig{}
|
||||||
|
filename := filepath.Join(dir, "config.json")
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, fmt.Errorf("loading config file: %w", err)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
if err := json.NewDecoder(file).Decode(cfg); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return nil, fmt.Errorf("parsing config file (%s): %w", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for address, auth := range cfg.Auths {
|
||||||
|
if auth.Auth != "" {
|
||||||
|
if auth.Username, auth.Password, err = decodeAuth(auth.Auth); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config file (%s): %w", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.Auth, auth.ServerAddress = "", address
|
||||||
|
cfg.Auths[address] = auth
|
||||||
|
}
|
||||||
|
if value := os.Getenv("DOCKER_AUTH_CONFIG"); value != "" {
|
||||||
|
if cfg.env, err = parseDockerAuthConfigEnv(value); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("Failed to create credential store from DOCKER_AUTH_CONFIG: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAuth(auth string) (string, string, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(auth)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
username, password, ok := strings.Cut(string(decoded), ":")
|
||||||
|
if !ok || username == "" {
|
||||||
|
return "", "", errors.New("invalid auth configuration file")
|
||||||
|
}
|
||||||
|
return username, strings.Trim(password, "\x00"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDockerAuthConfigEnv(value string) (map[string]registry.AuthConfig, error) {
|
||||||
|
var env struct {
|
||||||
|
Auths map[string]struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
} `json:"auths"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(value))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&env); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if decoder.More() {
|
||||||
|
return nil, errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object")
|
||||||
|
}
|
||||||
|
auths := make(map[string]registry.AuthConfig, len(env.Auths))
|
||||||
|
for address, entry := range env.Auths {
|
||||||
|
if entry.Auth == "" {
|
||||||
|
return nil, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", address)
|
||||||
|
}
|
||||||
|
username, password, err := decodeAuth(entry.Auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
auths[address] = registry.AuthConfig{Username: username, Password: password, ServerAddress: address}
|
||||||
|
}
|
||||||
|
return auths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerConfig) authConfig(ctx context.Context, key string) (registry.AuthConfig, error) {
|
||||||
|
if auth, ok := c.env[key]; ok {
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
helper := c.CredsStore
|
||||||
|
if named, ok := c.CredHelpers[key]; ok {
|
||||||
|
helper = named
|
||||||
|
}
|
||||||
|
if helper == "" {
|
||||||
|
return c.fileAuth(key), nil
|
||||||
|
}
|
||||||
|
auth := c.fileAuth(key)
|
||||||
|
stored, err := helperCredentials(ctx, helper, key)
|
||||||
|
if err != nil {
|
||||||
|
return auth, err
|
||||||
|
}
|
||||||
|
auth.Username, auth.Password, auth.IdentityToken, auth.ServerAddress = stored.Username, stored.Password, stored.IdentityToken, stored.ServerAddress
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerConfig) fileAuth(key string) registry.AuthConfig {
|
||||||
|
if auth, ok := c.Auths[key]; ok {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
for address, auth := range c.Auths {
|
||||||
|
if registryHostname(address) == key {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registry.AuthConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerConfig) allAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) {
|
||||||
|
var auths map[string]registry.AuthConfig
|
||||||
|
if c.CredsStore == "" {
|
||||||
|
auths = maps.Clone(c.Auths)
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
if auths, err = c.helperAuthConfigs(ctx); err != nil {
|
||||||
|
if c.env == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Warnf("Could not list the credentials of docker-credential-%s: %v", c.CredsStore, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if auths == nil {
|
||||||
|
auths = map[string]registry.AuthConfig{}
|
||||||
|
}
|
||||||
|
maps.Copy(auths, c.env)
|
||||||
|
if c.CredsStore == "" {
|
||||||
|
c.Auths = maps.Clone(auths) // docker's file store holds the DOCKER_AUTH_CONFIG entries after listing, and credHelpers set to "" read it
|
||||||
|
}
|
||||||
|
for address := range c.CredHelpers {
|
||||||
|
auth, err := c.authConfig(ctx, registryAuthConfigKey(address))
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("Failed to get credentials for registry: %s: %v", address, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
auths[address] = auth
|
||||||
|
}
|
||||||
|
return auths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerConfig) helperAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) {
|
||||||
|
out, err := runCredentialHelper(ctx, c.CredsStore, "list", "unused")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listing credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
var addresses map[string]string
|
||||||
|
if err := json.NewDecoder(bytes.NewReader(out)).Decode(&addresses); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
auths := make(map[string]registry.AuthConfig, len(addresses))
|
||||||
|
for address := range addresses {
|
||||||
|
stored, err := helperCredentials(ctx, c.CredsStore, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
auth := c.Auths[address]
|
||||||
|
auth.Username, auth.Password, auth.IdentityToken = stored.Username, stored.Password, stored.IdentityToken
|
||||||
|
if auth.ServerAddress == "" {
|
||||||
|
auth.ServerAddress = stored.ServerAddress
|
||||||
|
}
|
||||||
|
auths[address] = auth
|
||||||
|
}
|
||||||
|
return auths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func helperCredentials(ctx context.Context, helper, address string) (registry.AuthConfig, error) {
|
||||||
|
out, err := runCredentialHelper(ctx, helper, "get", address)
|
||||||
|
if err != nil {
|
||||||
|
if strings.TrimSpace(string(out)) == "credentials not found in native keychain" {
|
||||||
|
return registry.AuthConfig{}, nil
|
||||||
|
}
|
||||||
|
return registry.AuthConfig{}, fmt.Errorf("error getting credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
var stored struct{ Username, Secret string }
|
||||||
|
if err := json.NewDecoder(bytes.NewReader(out)).Decode(&stored); err != nil {
|
||||||
|
return registry.AuthConfig{}, err
|
||||||
|
}
|
||||||
|
if stored.Username == "<token>" {
|
||||||
|
return registry.AuthConfig{IdentityToken: stored.Secret, ServerAddress: address}, nil
|
||||||
|
}
|
||||||
|
return registry.AuthConfig{Username: stored.Username, Password: stored.Secret, ServerAddress: address}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCredentialHelper(ctx context.Context, helper, action, input string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "docker-credential-"+helper, action)
|
||||||
|
cmd.Stdin = strings.NewReader(input)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Output()
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryHostname(address string) string {
|
||||||
|
if _, rest, ok := strings.Cut(address, "://"); ok {
|
||||||
|
if u, err := url.Parse(address); err == nil && u.Hostname() != "" {
|
||||||
|
if u.Port() == "" {
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(u.Hostname(), u.Port())
|
||||||
|
}
|
||||||
|
if host, _, _ := strings.Cut(rest, "/"); host != "" {
|
||||||
|
if colon := strings.LastIndex(host, ":"); strings.Count(host, ":") > 1 && !strings.HasPrefix(host, "[") && isDigits(host[colon+1:]) {
|
||||||
|
return net.JoinHostPort(host[:colon], host[colon+1:])
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, _, _ := strings.Cut(address, "/")
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigits(s string) bool {
|
||||||
|
return s != "" && strings.Trim(s, "0123456789") == ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows && !(WITHOUT_DOCKER || !(linux || darwin || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/moby/moby/api/types/registry"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDockerAuthConfigFromFileHelpersAndEnv(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-fake"), []byte("#!/bin/sh\nread -r server\n[ \"$1\" = get ] && [ \"$server\" = helper.example.com ] && exec echo '{\"Username\":\"helped\",\"Secret\":\"pw\"}'\necho 'credentials not found in native keychain'\nexit 1\n"), 0o755))
|
||||||
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
auth := func(credentials string) string { return base64.StdEncoding.EncodeToString([]byte(credentials)) }
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"auths": {"https://legacy.example.com/v1/": {"auth": "`+auth("legacy:pw")+`"}}, "credHelpers": {"helper.example.com": "fake", "gone.example.com": "fake", "env-legacy.example.com": ""}}`), 0o600))
|
||||||
|
t.Setenv("DOCKER_CONFIG", dir)
|
||||||
|
t.Setenv("DOCKER_AUTH_CONFIG", `{"auths": {"env.example.com": {"auth": "`+auth("env:pw")+`"}, "https://env-legacy.example.com/v1/": {"auth": "`+auth("env:pw")+`"}}}`)
|
||||||
|
|
||||||
|
for image, want := range map[string]registry.AuthConfig{
|
||||||
|
"legacy.example.com/app": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"},
|
||||||
|
"helper.example.com/app": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"},
|
||||||
|
"env.example.com/app": {Username: "env", Password: "pw", ServerAddress: "env.example.com"},
|
||||||
|
"gone.example.com/app": {},
|
||||||
|
} {
|
||||||
|
got, err := LoadDockerAuthConfig(t.Context(), image)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, image)
|
||||||
|
}
|
||||||
|
assert.Equal(t, map[string]registry.AuthConfig{
|
||||||
|
"https://legacy.example.com/v1/": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"},
|
||||||
|
"helper.example.com": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"},
|
||||||
|
"gone.example.com": {},
|
||||||
|
"env.example.com": {Username: "env", Password: "pw", ServerAddress: "env.example.com"},
|
||||||
|
"https://env-legacy.example.com/v1/": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"},
|
||||||
|
"env-legacy.example.com": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"},
|
||||||
|
}, LoadDockerAuthConfigs(t.Context()))
|
||||||
|
}
|
||||||
@@ -7,15 +7,18 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/go-archive"
|
|
||||||
"github.com/moby/go-archive/compression"
|
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
"github.com/moby/patternmatcher"
|
"github.com/moby/patternmatcher"
|
||||||
"github.com/moby/patternmatcher/ignorefile"
|
"github.com/moby/patternmatcher/ignorefile"
|
||||||
@@ -115,14 +118,140 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
includes = append(includes, ".dockerignore", relDockerfile)
|
includes = append(includes, ".dockerignore", relDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
pm, err := patternmatcher.New(excludes)
|
||||||
Compression: compression.None,
|
|
||||||
ExcludePatterns: excludes,
|
|
||||||
IncludeFiles: includes,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reader, writer := io.Pipe()
|
||||||
return buildCtx, nil
|
go func() { writer.CloseWithError(writeBuildContext(ctx, writer, contextDir, includes, pm)) }()
|
||||||
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, includes []string, pm *patternmatcher.PatternMatcher) error {
|
||||||
|
tw := tar.NewWriter(w)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
hardlinks := map[uint64]string{}
|
||||||
|
buffer := make([]byte, 32*1024)
|
||||||
|
for _, include := range includes {
|
||||||
|
include = filepath.FromSlash(include)
|
||||||
|
var parents []parentMatch
|
||||||
|
root := strings.TrimSuffix(contextDir, string(filepath.Separator)) + string(filepath.Separator) + include // unlike filepath.Join, follows a symlinked context dir as docker build does
|
||||||
|
err := filepath.WalkDir(root, func(file string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return leaveOut(ctx, file, err)
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(contextDir, file)
|
||||||
|
if err != nil || rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rel != include {
|
||||||
|
for len(parents) > 0 && !strings.HasPrefix(rel, parents[len(parents)-1].dir+string(filepath.Separator)) {
|
||||||
|
parents = parents[:len(parents)-1]
|
||||||
|
}
|
||||||
|
var parentInfo patternmatcher.MatchInfo
|
||||||
|
if len(parents) > 0 {
|
||||||
|
parentInfo = parents[len(parents)-1].info
|
||||||
|
}
|
||||||
|
skip, info, err := pm.MatchesUsingParentResults(rel, parentInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
parents = append(parents, parentMatch{rel, info})
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
if entry.IsDir() && !hasExceptionWithin(pm, rel) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen[rel] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen[rel] = true
|
||||||
|
return addBuildContextEntry(ctx, tw, file, rel, entry, hardlinks, buffer)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type parentMatch struct {
|
||||||
|
dir string
|
||||||
|
info patternmatcher.MatchInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExceptionWithin(pm *patternmatcher.PatternMatcher, dir string) bool {
|
||||||
|
dirPrefix := dir + string(filepath.Separator)
|
||||||
|
for _, pattern := range pm.Patterns() {
|
||||||
|
if pattern.Exclusion() && strings.HasPrefix(pattern.String()+string(filepath.Separator), dirPrefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveOut(ctx context.Context, file string, err error) error {
|
||||||
|
common.Logger(ctx).Warnf("Leaving %s out of the build context: %v", file, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addBuildContextEntry(ctx context.Context, tw *tar.Writer, file, rel string, entry fs.DirEntry, hardlinks map[uint64]string, buffer []byte) error {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return leaveOut(ctx, file, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSocket != 0 {
|
||||||
|
return nil // tar has no socket type
|
||||||
|
}
|
||||||
|
var link string
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if link, err = os.Readlink(file); err != nil {
|
||||||
|
return leaveOut(ctx, file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header, err := tar.FileInfoHeader(noNameLookups{info}, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = filepath.ToSlash(rel)
|
||||||
|
inode, linked := hardlinkInode(info)
|
||||||
|
if target, ok := hardlinks[inode]; linked && ok {
|
||||||
|
header.Typeflag, header.Linkname, header.Size = tar.TypeLink, target, 0
|
||||||
|
}
|
||||||
|
var content *os.File
|
||||||
|
if header.Typeflag == tar.TypeReg && header.Size > 0 {
|
||||||
|
if content, err = os.Open(file); err != nil {
|
||||||
|
return leaveOut(ctx, file, err)
|
||||||
|
}
|
||||||
|
defer content.Close()
|
||||||
|
}
|
||||||
|
if linked && header.Typeflag != tar.TypeLink {
|
||||||
|
hardlinks[inode] = header.Name
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
header.Name += "/"
|
||||||
|
}
|
||||||
|
header.Format = tar.FormatPAX
|
||||||
|
header.ModTime = header.ModTime.Truncate(time.Second)
|
||||||
|
header.AccessTime, header.ChangeTime = time.Time{}, time.Time{}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
header.Mode = header.Mode&0o755 | 0o111 // Windows has no exec bit, so mark everything executable like docker build
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if content != nil {
|
||||||
|
_, err = io.CopyBuffer(tw, struct{ io.Reader }{content}, buffer) // hides os.File's WriteTo, which would allocate a buffer per file
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type noNameLookups struct{ fs.FileInfo } // leaves Uname and Gname empty like docker build, so packing never waits on NSS
|
||||||
|
|
||||||
|
func (noNameLookups) Uname() (string, error) { return "", nil }
|
||||||
|
|
||||||
|
func (noNameLookups) Gname() (string, error) { return "", nil }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
||||||
|
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
||||||
|
|
||||||
|
contexts := []string{dir}
|
||||||
|
if link := filepath.Join(t.TempDir(), "link"); os.Symlink(dir, link) == nil {
|
||||||
|
contexts = append(contexts, link+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, contextDir := range contexts {
|
||||||
|
buildContext, err := createBuildContext(t.Context(), contextDir, "Dockerfile")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var names []string
|
||||||
|
for reader := tar.NewReader(buildContext); ; {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
names = append(names, header.Name)
|
||||||
|
}
|
||||||
|
require.NoError(t, buildContext.Close())
|
||||||
|
require.Equal(t, []string{".dockerignore", "a.txt", "build/keep.txt", "empty/", "Dockerfile"}, names, contextDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hardlinkInode(info fs.FileInfo) (uint64, bool) {
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok && !info.IsDir() && stat.Nlink > 1 {
|
||||||
|
return stat.Ino, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateBuildContextKeepsHardlinkedSpecialFilesLinked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "first"), 0o644))
|
||||||
|
require.NoError(t, os.Link(filepath.Join(dir, "first"), filepath.Join(dir, "second")))
|
||||||
|
|
||||||
|
buildContext, err := createBuildContext(t.Context(), dir, "Dockerfile")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer buildContext.Close()
|
||||||
|
reader := tar.NewReader(buildContext)
|
||||||
|
first, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, byte(tar.TypeFifo), first.Typeflag)
|
||||||
|
second, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, byte(tar.TypeLink), second.Typeflag)
|
||||||
|
assert.Equal(t, first.Name, second.Linkname)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import "io/fs"
|
||||||
|
|
||||||
|
func hardlinkInode(_info fs.FileInfo) (uint64, bool) { return 0, false }
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/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 ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName
|
||||||
// * jsontext in place of json.Compact for the seccomp profile
|
// * 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
|
||||||
//
|
//
|
||||||
@@ -33,14 +34,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
cdi "tags.cncf.io/container-device-interface/pkg/parser"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -388,7 +387,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
volumes := copts.volumes.GetMap()
|
volumes := copts.volumes.GetMap()
|
||||||
// add any bind targets to the list of container volumes
|
// add any bind targets to the list of container volumes
|
||||||
for bind := range volumes {
|
for bind := range volumes {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -396,7 +395,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
if parsed.Source != "" {
|
if parsed.Source != "" {
|
||||||
toBind := bind
|
toBind := bind
|
||||||
|
|
||||||
if parsed.Type == string(mount.TypeBind) {
|
if parsed.Type == mount.TypeBind {
|
||||||
if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok {
|
if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok {
|
||||||
if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") {
|
if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") {
|
||||||
if absHostPart, err := filepath.Abs(hostPart); err == nil {
|
if absHostPart, err := filepath.Abs(hostPart); err == nil {
|
||||||
@@ -504,7 +503,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
deviceMappings := make([]container.DeviceMapping, 0, len(devices))
|
deviceMappings := make([]container.DeviceMapping, 0, len(devices))
|
||||||
cdiDeviceNames := make([]string, 0, len(devices))
|
cdiDeviceNames := make([]string, 0, len(devices))
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
if cdi.IsQualifiedName(device) {
|
if cdiDeviceName.MatchString(device) {
|
||||||
cdiDeviceNames = append(cdiDeviceNames, device)
|
cdiDeviceNames = append(cdiDeviceNames, device)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1196,8 @@ func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
|||||||
return converted, nil
|
return converted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cdiDeviceName = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?/[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?=[a-zA-Z0-9]([a-zA-Z0-9_.:-]*[a-zA-Z0-9])?$`)
|
||||||
|
|
||||||
func convertPortMap(bindings nat.PortMap) (network.PortMap, error) {
|
func convertPortMap(bindings nat.PortMap) (network.PortMap, error) {
|
||||||
converted := make(network.PortMap, len(bindings))
|
converted := make(network.PortMap, len(bindings))
|
||||||
for port, portBindings := range bindings {
|
for port, portBindings := range bindings {
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
networktypes "github.com/moby/moby/api/types/network"
|
networktypes "github.com/moby/moby/api/types/network"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"gotest.tools/v3/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
is "gotest.tools/v3/assert/cmp"
|
"github.com/stretchr/testify/require"
|
||||||
"gotest.tools/v3/skip"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mustParseMAC(s string) networktypes.HardwareAddr {
|
func mustParseMAC(s string) networktypes.HardwareAddr {
|
||||||
@@ -90,7 +88,7 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
return config, hostConfig, nwConfig
|
return config, hostConfig, nwConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +154,12 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, hostConfig, netConfig := mustParse(t, tc.input)
|
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||||
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
assert.Equal(t, tc.expHostConfigLinks, hostConfig.Links)
|
||||||
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
assert.Len(t, netConfig.EndpointsConfig, len(tc.expNetConfigLinks))
|
||||||
for netName, expLinks := range tc.expNetConfigLinks {
|
for netName, expLinks := range tc.expNetConfigLinks {
|
||||||
nc, ok := netConfig.EndpointsConfig[netName]
|
nc, ok := netConfig.EndpointsConfig[netName]
|
||||||
assert.Assert(t, ok)
|
require.True(t, ok)
|
||||||
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
assert.Equal(t, expLinks, nc.Links)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -169,15 +167,15 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseRunWithoutUmask(t *testing.T) {
|
func TestParseRunWithoutUmask(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Assert(t, hostConfig.Umask == nil)
|
require.Nil(t, hostConfig.Umask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunUmask(t *testing.T) {
|
func TestParseRunUmask(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Assert(t, hostConfig.Umask != nil)
|
require.NotNil(t, hostConfig.Umask)
|
||||||
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
require.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunAttach(t *testing.T) {
|
func TestParseRunAttach(t *testing.T) {
|
||||||
@@ -225,9 +223,9 @@ func TestParseRunAttach(t *testing.T) {
|
|||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
config, _, _ := mustParse(t, tc.input)
|
config, _, _ := mustParse(t, tc.input)
|
||||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
require.Equal(t, tc.expected.AttachStdin, config.AttachStdin)
|
||||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
require.Equal(t, tc.expected.AttachStdout, config.AttachStdout)
|
||||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
require.Equal(t, tc.expected.AttachStderr, config.AttachStderr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +271,7 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
|||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||||
assert.Error(t, flags.Parse(tc.args), tc.error)
|
require.EqualError(t, flags.Parse(tc.args), tc.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,23 +394,23 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
|
|||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
args := []string{"--memory=invalid", "img", "cmd"}
|
args := []string{"--memory=invalid", "img", "cmd"}
|
||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
require.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||||
|
|
||||||
_, hostconfig, _ := mustParse(t, "--memory=1G")
|
_, hostconfig, _ := mustParse(t, "--memory=1G")
|
||||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
assert.Equal(t, int64(1073741824), hostconfig.Memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithMemorySwap(t *testing.T) {
|
func TestParseWithMemorySwap(t *testing.T) {
|
||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
require.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||||
|
|
||||||
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
|
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
|
||||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
assert.Equal(t, int64(1073741824), hostconfig.MemorySwap)
|
||||||
|
|
||||||
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
|
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
|
||||||
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
assert.Equal(t, int64(-1), hostconfig.MemorySwap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHostname(t *testing.T) {
|
func TestParseHostname(t *testing.T) {
|
||||||
@@ -474,7 +472,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
for expose, expectedError := range tests {
|
for expose, expectedError := range tests {
|
||||||
t.Run(expose, func(t *testing.T) {
|
t.Run(expose, func(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
assert.Error(t, err, expectedError)
|
require.EqualError(t, err, expectedError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -489,10 +487,10 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
for expose, exposedPorts := range tests {
|
for expose, exposedPorts := range tests {
|
||||||
t.Run(expose, func(t *testing.T) {
|
t.Run(expose, func(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
for _, port := range exposedPorts {
|
for _, port := range exposedPorts {
|
||||||
_, ok := config.ExposedPorts[port]
|
_, ok := config.ExposedPorts[port]
|
||||||
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -501,18 +499,20 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
t.Run("merge with published", func(t *testing.T) {
|
t.Run("merge with published", func(t *testing.T) {
|
||||||
// Merge with actual published port
|
// Merge with actual published port
|
||||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
assert.Len(t, config.ExposedPorts, 2)
|
||||||
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
_, ok := config.ExposedPorts[port]
|
_, ok := config.ExposedPorts[port]
|
||||||
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDevice(t *testing.T) {
|
func TestParseDevice(t *testing.T) {
|
||||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("Windows and macOS validate server-side")
|
||||||
|
}
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
devices []string
|
devices []string
|
||||||
deviceMapping *container.DeviceMapping
|
deviceMapping *container.DeviceMapping
|
||||||
@@ -586,17 +586,17 @@ func TestParseDevice(t *testing.T) {
|
|||||||
|
|
||||||
_, hostconfig, _, err := parseRun(args)
|
_, hostconfig, _, err := parseRun(args)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if tc.deviceMapping != nil {
|
if tc.deviceMapping != nil {
|
||||||
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
if assert.Len(t, hostconfig.Devices, 1) {
|
||||||
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
assert.Equal(t, *tc.deviceMapping, hostconfig.Devices[0])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assert.Check(t, is.Len(hostconfig.Devices, 0))
|
assert.Empty(t, hostconfig.Devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests))
|
assert.Equal(t, tc.deviceRequests, hostconfig.DeviceRequests)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,11 +665,11 @@ func TestParseDeviceByServerOS(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got, err := parseDevice(tc.device, tc.serverOS)
|
got, err := parseDevice(tc.device, tc.serverOS)
|
||||||
if tc.wantErr != "" {
|
if tc.wantErr != "" {
|
||||||
assert.Error(t, err, tc.wantErr)
|
require.EqualError(t, err, tc.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, tc.want)
|
require.Equal(t, tc.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,13 +848,13 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
||||||
|
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
assert.Error(t, err, tc.expectedErr)
|
require.EqualError(t, err, tc.expectedErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
require.Equal(t, tc.expectedHostCfg.NetworkMode, hConfig.NetworkMode)
|
||||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
require.Equal(t, tc.expected, nwConfig.EndpointsConfig)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -863,24 +863,24 @@ func TestParseModes(t *testing.T) {
|
|||||||
// pid ko
|
// pid ko
|
||||||
flags, copts := setupRunFlags()
|
flags, copts := setupRunFlags()
|
||||||
args := []string{"--pid=container:", "img", "cmd"}
|
args := []string{"--pid=container:", "img", "cmd"}
|
||||||
assert.NilError(t, flags.Parse(args))
|
require.NoError(t, flags.Parse(args))
|
||||||
_, err := parse(flags, copts, runtime.GOOS)
|
_, err := parse(flags, copts, runtime.GOOS)
|
||||||
assert.ErrorContains(t, err, "--pid: invalid PID mode")
|
require.ErrorContains(t, err, "--pid: invalid PID mode")
|
||||||
|
|
||||||
// pid ok
|
// pid ok
|
||||||
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if !hostconfig.PidMode.Valid() {
|
if !hostconfig.PidMode.Valid() {
|
||||||
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uts ko
|
// uts ko
|
||||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
require.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||||
|
|
||||||
// uts ok
|
// uts ok
|
||||||
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if !hostconfig.UTSMode.Valid() {
|
if !hostconfig.UTSMode.Valid() {
|
||||||
t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode)
|
t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode)
|
||||||
}
|
}
|
||||||
@@ -892,11 +892,11 @@ func TestRunFlagsParseShmSize(t *testing.T) {
|
|||||||
args := []string{"--shm-size=a128m", "img", "cmd"}
|
args := []string{"--shm-size=a128m", "img", "cmd"}
|
||||||
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, expectedErr)
|
require.ErrorContains(t, err, expectedErr)
|
||||||
|
|
||||||
// shm-size ok
|
// shm-size ok
|
||||||
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if hostconfig.ShmSize != 134217728 {
|
if hostconfig.ShmSize != 134217728 {
|
||||||
t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize)
|
t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize)
|
||||||
}
|
}
|
||||||
@@ -967,11 +967,11 @@ func TestParseRestartPolicy(t *testing.T) {
|
|||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
assert.Check(t, is.Error(err, tc.expectedErr))
|
assert.Nil(t, hostConfig)
|
||||||
assert.Check(t, is.Nil(hostConfig))
|
assert.EqualError(t, err, tc.expectedErr)
|
||||||
} else {
|
} else {
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
assert.Equal(t, tc.expected, hostConfig.RestartPolicy)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -980,7 +980,7 @@ func TestParseRestartPolicy(t *testing.T) {
|
|||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
const expected = "conflicting options: cannot specify both --restart and --rm"
|
const expected = "conflicting options: cannot specify both --restart and --rm"
|
||||||
assert.Check(t, is.Error(err, expected))
|
assert.EqualError(t, err, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHealth(t *testing.T) {
|
func TestParseHealth(t *testing.T) {
|
||||||
@@ -1118,12 +1118,14 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy
|
|||||||
|
|
||||||
func TestParseEntryPoint(t *testing.T) {
|
func TestParseEntryPoint(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
assert.Equal(t, []string{"anything"}, config.Entrypoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDevice(t *testing.T) {
|
func TestValidateDevice(t *testing.T) {
|
||||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("Windows and macOS validate server-side")
|
||||||
|
}
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"/home",
|
"/home",
|
||||||
"/home:/home",
|
"/home:/home",
|
||||||
@@ -1222,22 +1224,22 @@ func TestValidateDeviceByServerOS(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got, err := validateDevice(tc.value, tc.serverOS)
|
got, err := validateDevice(tc.value, tc.serverOS)
|
||||||
if tc.wantError != "" {
|
if tc.wantError != "" {
|
||||||
assert.Error(t, err, tc.wantError)
|
require.EqualError(t, err, tc.wantError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, tc.want)
|
require.Equal(t, tc.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||||
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, "c 1:3 rwm")
|
require.Equal(t, "c 1:3 rwm", got)
|
||||||
|
|
||||||
_, err = validateDeviceCgroupRule("invalid")
|
_, err = validateDeviceCgroupRule("invalid")
|
||||||
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
require.EqualError(t, err, "invalid device cgroup format 'invalid'")
|
||||||
|
|
||||||
if invalidParameter(nil) != nil {
|
if invalidParameter(nil) != nil {
|
||||||
t.Fatal("invalidParameter(nil) should be nil")
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
@@ -1245,9 +1247,9 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
|||||||
cause := errors.New("bad input")
|
cause := errors.New("bad input")
|
||||||
err = invalidParameter(cause)
|
err = invalidParameter(cause)
|
||||||
var invalid interface{ InvalidParameter() }
|
var invalid interface{ InvalidParameter() }
|
||||||
assert.Assert(t, errors.As(err, &invalid))
|
require.ErrorAs(t, err, &invalid)
|
||||||
assert.Assert(t, errors.Is(err, cause))
|
require.ErrorIs(t, err, cause)
|
||||||
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
require.Condition(t, func() bool { return invalidParameter(err) == err })
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
@@ -1298,9 +1300,9 @@ func TestParseSystemPaths(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
||||||
assert.DeepEqual(t, securityOpts, tc.out)
|
require.Equal(t, tc.out, securityOpts)
|
||||||
assert.DeepEqual(t, maskedPaths, tc.masked)
|
require.Equal(t, tc.masked, maskedPaths)
|
||||||
assert.DeepEqual(t, readonlyPaths, tc.readonly)
|
require.Equal(t, tc.readonly, readonlyPaths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,9 +1324,9 @@ func TestConvertToStandardNotation(t *testing.T) {
|
|||||||
for key, ports := range valid {
|
for key, ports := range valid {
|
||||||
convertedPorts, err := convertToStandardNotation(ports)
|
convertedPorts, err := convertToStandardNotation(ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
assert.DeepEqual(t, strings.Split(key, " "), convertedPorts)
|
require.Equal(t, strings.Split(key, " "), convertedPorts)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ports := range invalid {
|
for _, ports := range invalid {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
@@ -30,11 +30,23 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform {
|
if platform == "" || platform == "any" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
requested, err := parsePlatform(platform)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return platformMatches(requested, &specs.Platform{OS: inspectImage.Os, Architecture: inspectImage.Architecture, Variant: inspectImage.Variant}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformMatches(requested, image *specs.Platform) bool {
|
||||||
|
variant := image.Variant
|
||||||
|
if variant == "" {
|
||||||
|
variant = map[string]string{"amd64": "v1", "arm": "v7", "arm64": "v8"}[image.Architecture]
|
||||||
|
}
|
||||||
|
return image.OS == requested.OS && image.Architecture == requested.Architecture && (requested.Variant == "" || requested.Variant == variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes image from local store, the function is used to run different
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||||
@@ -41,7 +41,7 @@ func buildScratchImage(t *testing.T, platform string) string {
|
|||||||
|
|
||||||
func TestImageExistsLocally(t *testing.T) {
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
// a non-existent image is reported absent
|
// a non-existent image is reported absent
|
||||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type dockerMessage struct {
|
type dockerMessage struct {
|
||||||
@@ -26,7 +26,7 @@ type dockerMessage struct {
|
|||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isError bool) error {
|
||||||
if dockerResponse == nil {
|
if dockerResponse == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLog(logger logrus.FieldLogger, isError bool, format string, args ...any) {
|
func writeLog(logger *logging.Logger, isError bool, format string, args ...any) {
|
||||||
if isError {
|
if isError {
|
||||||
logger.Errorf(format, args...)
|
logger.Errorf(format, args...)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID,
|
|||||||
networks:
|
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 cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,9 @@ networks:
|
|||||||
continue networks
|
continue networks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if 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
|
||||||
}
|
}
|
||||||
@@ -87,12 +91,17 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
return createDockerNetwork(ctx, cli, name, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
|
||||||
// Only create the network if it doesn't exist
|
// Only create the network if it doesn't exist
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var options map[string]string
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
@@ -100,6 +109,10 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||||
return nil
|
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 {
|
for i := range networkCreateAttempts {
|
||||||
@@ -116,6 +129,7 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
Scope: "local",
|
Scope: "local",
|
||||||
EnableIPv4: opts.EnableIPv4,
|
EnableIPv4: opts.EnableIPv4,
|
||||||
EnableIPv6: opts.EnableIPv6,
|
EnableIPv6: opts.EnableIPv6,
|
||||||
|
Options: options,
|
||||||
Labels: runnerLabels(opts.RunnerUUID),
|
Labels: runnerLabels(opts.RunnerUUID),
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -126,7 +140,6 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
if runnerUUID == "" {
|
if runnerUUID == "" {
|
||||||
@@ -194,37 +207,36 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
return removeNetwork(ctx, cli, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure that all network of the specified name are removed
|
func removeNetwork(ctx context.Context, cli client.APIClient, name string) error {
|
||||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{}) // NetworkRemove by name refuses duplicates, so remove each by ID
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
if n.Name == name {
|
if n.Name != name {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// it holds a subnet out of the daemon's pool until something reclaims it
|
|
||||||
if len(result.Network.Containers) != 0 {
|
|
||||||
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
|
if cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(result.Network.Containers) != 0 {
|
||||||
|
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the orphan cleanup reclaims it once they are gone", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
// 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) {
|
func IsolatedCacheContainer(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestIsolatedCacheContainer(t *testing.T) {
|
func TestIsolatedCacheContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
@@ -57,6 +57,27 @@ func TestIsAddressPoolExhausted(t *testing.T) {
|
|||||||
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")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
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) {
|
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)
|
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
}
|
}
|
||||||
@@ -66,7 +87,7 @@ func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
connect := func(answer error) error {
|
connect := func(answer error) error {
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
@@ -95,8 +116,8 @@ func TestConnectAndDisconnectNetwork(t *testing.T) {
|
|||||||
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
// 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
|
// 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.
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
func TestRemoveNetworks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
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)
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
@@ -107,11 +128,13 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
{ID: "starting"},
|
{ID: "starting"},
|
||||||
{ID: "cached"},
|
{ID: "cached"},
|
||||||
{ID: "shared"},
|
{ID: "shared"},
|
||||||
|
{ID: "gone"},
|
||||||
|
{ID: "raced"},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil).Twice()
|
||||||
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).Twice()
|
||||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||||
Network: network.Network{Created: cutoff.Add(time.Second)},
|
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||||
@@ -121,13 +144,28 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "gone", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, cerrdefs.ErrNotFound).Twice()
|
||||||
|
client.On("NetworkInspect", ctx, "raced", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, nil).Twice()
|
||||||
|
client.On("NetworkRemove", ctx, "raced", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrNotFound).Twice()
|
||||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
Return(mobyclient.NetworkRemoveResult{}, nil).Twice()
|
||||||
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||||
Return(nil).Once()
|
Return(nil).Once()
|
||||||
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||||
|
|
||||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
||||||
|
|
||||||
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
|
{ID: "orphan", Name: "job"},
|
||||||
|
{ID: "busy", Name: "job"},
|
||||||
|
{ID: "gone", Name: "job"},
|
||||||
|
{ID: "raced", Name: "job"},
|
||||||
|
{ID: "shared", Name: "other"},
|
||||||
|
}}, nil)
|
||||||
|
require.NoError(t, removeNetwork(ctx, client, "job"))
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -61,3 +61,26 @@ func TestParsePlatform(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformMatches(t *testing.T) {
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
requested, image string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"linux/amd64", "linux/amd64", true},
|
||||||
|
{"linux/amd64/v1", "linux/amd64", true},
|
||||||
|
{"linux/arm64", "linux/amd64", false},
|
||||||
|
{"linux/arm64/v8", "linux/arm64", true},
|
||||||
|
{"linux/arm/v7", "linux/arm/v7", true},
|
||||||
|
{"linux/arm", "linux/arm/v7", true},
|
||||||
|
{"linux/arm/v6", "linux/arm/v7", false},
|
||||||
|
{"linux/arm", "linux/arm/v6", true},
|
||||||
|
{"windows/arm/v7", "linux/arm/v7", false},
|
||||||
|
} {
|
||||||
|
requested, err := parsePlatform(testcase.requested)
|
||||||
|
require.NoError(t, err)
|
||||||
|
image, err := parsePlatform(testcase.image)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testcase.want, platformMatches(requested, image), "%s on %s", testcase.requested, testcase.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jobLabel = "com.gitea.runner.job"
|
jobLabel = "com.gitea.runner.job."
|
||||||
maxCreateBody = 8 << 20
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
dockerProxyProbeTimeout = 5 * time.Second
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
@@ -302,7 +302,7 @@ func rewriteCreate(r *http.Request, job string, mounts map[string]string) error
|
|||||||
if config.Labels == nil {
|
if config.Labels == nil {
|
||||||
config.Labels = make(map[string]string)
|
config.Labels = make(map[string]string)
|
||||||
}
|
}
|
||||||
config.Labels[jobLabel] = job
|
config.Labels[jobLabel+job] = "" // per-job key, so nested proxies keep each other's
|
||||||
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -560,7 +560,7 @@ func RemoveDockerJobResources(ctx context.Context, job string) error {
|
|||||||
|
|
||||||
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
filters := make(client.Filters).Add("label", jobLabel+job)
|
||||||
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||||
errs := []error{err}
|
errs := []error{err}
|
||||||
for _, c := range containers.Items {
|
for _, c := range containers.Items {
|
||||||
|
|||||||
@@ -146,52 +146,52 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
t.Run("labels creates", func(t *testing.T) {
|
t.Run("labels creates", func(t *testing.T) {
|
||||||
for _, testCase := range []struct{ path, body, want string }{
|
for _, testCase := range []struct{ path, body, want string }{
|
||||||
{
|
{
|
||||||
"/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.job-0":""}}`,
|
||||||
`{"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-0":"","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"}}`,
|
"/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"}}`,
|
`{"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"}]}}`,
|
"/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"}}`,
|
`{"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"}}}}]}}`,
|
"/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"}}`,
|
`{"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}`,
|
"/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"}}`,
|
`{"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,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
`{"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"}}`,
|
"/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"}}`,
|
`{"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"}}`,
|
"/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"}}`,
|
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", "",
|
"/volumes/create", "",
|
||||||
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", "null",
|
"/volumes/create", "null",
|
||||||
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
||||||
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||||
@@ -244,7 +244,7 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job.job-1":""}}`, string(<-bodies))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job.job-1":""}}`, string(<-bodies))
|
||||||
require.NoError(t, <-closed)
|
require.NoError(t, <-closed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -285,8 +285,8 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
containerFailure := errors.New("container removal failed")
|
containerFailure := errors.New("container removal failed")
|
||||||
listFailure := errors.New("listing failed")
|
listFailure := errors.New("listing failed")
|
||||||
volumeFailure := errors.New("volume removal failed")
|
volumeFailure := errors.New("volume removal failed")
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
filters := make(mobyclient.Filters).Add("label", jobLabel+"job-1")
|
||||||
cli := &mockDockerClient{}
|
cli := &mockDockerClient{}
|
||||||
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
||||||
@@ -312,7 +312,8 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
|
|
||||||
func TestDockerProxyWithDaemon(t *testing.T) {
|
func TestDockerProxyWithDaemon(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
cleanupCtx := context.WithoutCancel(ctx)
|
||||||
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
direct, err := GetDockerClient(ctx)
|
direct, err := GetDockerClient(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -337,22 +338,24 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
|||||||
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
|
t.Cleanup(func() {
|
||||||
|
_, _ = direct.ContainerRemove(cleanupCtx, created.ID, mobyclient.ContainerRemoveOptions{Force: true})
|
||||||
|
})
|
||||||
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
t.Cleanup(func() { _, _ = direct.NetworkRemove(cleanupCtx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
||||||
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
t.Cleanup(func() { _, _ = direct.VolumeRemove(cleanupCtx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
||||||
|
|
||||||
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
|
assert.Subset(t, inspected.Container.Config.Labels, map[string]string{"own": "1", jobLabel + job: ""})
|
||||||
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
|
assert.Contains(t, vol.Volume.Labels, jobLabel+job)
|
||||||
|
|
||||||
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -5,20 +5,19 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/config"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanImage(t *testing.T) {
|
func TestCleanImage(t *testing.T) {
|
||||||
@@ -36,18 +35,15 @@ func TestCleanImage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
imageOut := cleanImage(context.Background(), table.imageIn)
|
imageOut := cleanImage(t.Context(), table.imageIn)
|
||||||
assert.Equal(t, table.imageOut, imageOut)
|
assert.Equal(t, table.imageOut, imageOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetImagePullOptions(t *testing.T) {
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
orig := config.Dir()
|
t.Setenv("DOCKER_CONFIG", "/non-existent/docker")
|
||||||
t.Cleanup(func() { config.SetDir(orig) })
|
|
||||||
|
|
||||||
config.SetDir("/non-existent/docker")
|
|
||||||
|
|
||||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -61,7 +57,7 @@ func TestGetImagePullOptions(t *testing.T) {
|
|||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||||
|
|
||||||
config.SetDir("testdata/docker-pull-options")
|
t.Setenv("DOCKER_CONFIG", "testdata/docker-pull-options")
|
||||||
|
|
||||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
Image: "nektos/act",
|
Image: "nektos/act",
|
||||||
@@ -79,7 +75,7 @@ func TestIsPinnedImage(t *testing.T) {
|
|||||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||||
// whichever of the two shapes the daemon used.
|
// whichever of the two shapes the daemon used.
|
||||||
func TestLogDockerResponseError(t *testing.T) {
|
func TestLogDockerResponseError(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
streamErr := func(line string) error {
|
streamErr := func(line string) error {
|
||||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-148
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
@@ -25,50 +28,33 @@ import (
|
|||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDocker(t *testing.T) {
|
func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
|
||||||
requireDocker(t)
|
client := &mockDockerClient{}
|
||||||
ctx := context.Background()
|
client.On("ContainerInspect", mock.Anything, "amd64-job", mobyclient.ContainerInspectOptions{}).
|
||||||
client, err := GetDockerClient(ctx)
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Env: []string{
|
||||||
require.NoError(t, err)
|
"PATH=/opt/node/x64/bin:/usr/bin:/bin", "CONFLICT_VAR=container-value", "SOME_RANDOM_VAR=",
|
||||||
defer client.Close()
|
"ANOTHER_ONE=old-value", "ANOTHER_ONE=BUT_I_HAVE_VALUE", "LITERAL='quoted'=$HOME\nsecond line",
|
||||||
|
}}}}, nil)
|
||||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
|
||||||
ContextDir: "testdata",
|
|
||||||
ImageTag: "envmergetest",
|
|
||||||
})
|
|
||||||
|
|
||||||
err = dockerBuild(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
cr := &containerReference{
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "envmergetest",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
"PATH": "/custom/bin",
|
||||||
"RANDOM_VAR": "WITH_VALUE",
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
"ANOTHER_VAR": "",
|
"ANOTHER_VAR": "",
|
||||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
"CONFLICT_VAR": "step-value",
|
||||||
}
|
}
|
||||||
|
require.NoError(t, (&containerReference{cli: client, id: "amd64-job"}).UpdateFromImageEnv(&env)(t.Context()))
|
||||||
envExecutor := cr.extractFromImageEnv(&env)
|
|
||||||
err = envExecutor(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, map[string]string{
|
assert.Equal(t, map[string]string{
|
||||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
"PATH": "/custom/bin:/opt/node/x64/bin:/usr/bin:/bin",
|
||||||
"RANDOM_VAR": "WITH_VALUE",
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
"ANOTHER_VAR": "",
|
"ANOTHER_VAR": "",
|
||||||
"SOME_RANDOM_VAR": "",
|
"SOME_RANDOM_VAR": "",
|
||||||
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
"CONFLICT_VAR": "step-value",
|
||||||
|
"LITERAL": "'quoted'=$HOME\nsecond line",
|
||||||
}, env)
|
}, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +123,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)
|
||||||
@@ -175,7 +166,7 @@ func (m *mockConn) Close() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerExecAbort(t *testing.T) {
|
func TestDockerExecAbort(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
@@ -216,7 +207,7 @@ func TestDockerExecAbort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerExecFailure(t *testing.T) {
|
func TestDockerExecFailure(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
|
|
||||||
@@ -263,7 +254,7 @@ func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
|||||||
// attach() streaming goroutine has drained and flushed the container's output,
|
// attach() streaming goroutine has drained and flushed the container's output,
|
||||||
// so a final line without a trailing newline is not lost.
|
// so a final line without a trailing newline is not lost.
|
||||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||||
|
|
||||||
@@ -312,7 +303,7 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
statusCh <- container.WaitResponse{StatusCode: 2}
|
statusCh <- container.WaitResponse{StatusCode: 2}
|
||||||
@@ -363,8 +354,8 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
@@ -387,7 +378,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
assert.Empty(t, cr.id)
|
assert.Empty(t, cr.id)
|
||||||
}
|
}
|
||||||
assert.Empty(t, hook.AllEntries())
|
assert.Empty(t, hook.AllRecords())
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -397,7 +388,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
// create() lost its reply, must still be removed rather than leaking with its network. It
|
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||||
// was never started here, so it is not worth a kill of its own.
|
// was never started here, so it is not worth a kill of its own.
|
||||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||||
@@ -410,7 +401,7 @@ func TestRemoveWithoutIDUsesName(t *testing.T) {
|
|||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||||
// daemon with a torn-down container.
|
// daemon with a torn-down container.
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||||
boom := errors.New("daemon unreachable")
|
boom := errors.New("daemon unreachable")
|
||||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||||
@@ -468,7 +459,7 @@ func TestFindRevalidatesStaleID(t *testing.T) {
|
|||||||
// Every daemon entry point fails fast with a clear, container-named
|
// Every daemon entry point fails fast with a clear, container-named
|
||||||
// error when no live cr.id is known.
|
// error when no live cr.id is known.
|
||||||
func TestRejectsMissingContainer(t *testing.T) {
|
func TestRejectsMissingContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
@@ -493,10 +484,17 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
check("Inspect after removal", err)
|
check("Inspect after removal", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
|
||||||
// and the Copy completes against the fresh id.
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||||
ctx := context.Background()
|
for _, testcase := range []struct {
|
||||||
|
name, destination, extractionRoot, entry string
|
||||||
|
directory bool
|
||||||
|
}{
|
||||||
|
{name: "files", destination: "/var/run/act", extractionRoot: "/var/run/act", entry: "x"},
|
||||||
|
{name: "action below act volume", directory: true, destination: "/var/run/act/actions/checkout/", extractionRoot: "/var/run/act", entry: "actions/checkout/x"},
|
||||||
|
{name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
||||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
||||||
@@ -504,14 +502,27 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
{ID: "fresh", Names: []string{"/job-1"}},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "fresh", mock.Anything).
|
||||||
return opts.DestinationPath == "/var/run/act"
|
Run(func(args mock.Arguments) {
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
opts := args.Get(2).(mobyclient.CopyToContainerOptions)
|
||||||
|
assert.Equal(t, testcase.extractionRoot, opts.DestinationPath)
|
||||||
|
header, err := tar.NewReader(opts.Content).Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testcase.entry, header.Name)
|
||||||
|
}).Return(mobyclient.CopyToContainerResult{}, nil).Once()
|
||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
if testcase.directory {
|
||||||
|
source := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
||||||
|
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
||||||
|
} else {
|
||||||
|
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
|
}
|
||||||
assert.Equal(t, "fresh", cr.id)
|
assert.Equal(t, "fresh", cr.id)
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
@@ -523,6 +534,8 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
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",
|
||||||
@@ -578,19 +591,41 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
"/opt/app/cache:/cache",
|
"/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) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
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.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,33 +653,33 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
|||||||
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
||||||
|
|
||||||
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
||||||
|
|
||||||
_, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.ErrorContains(t, err, "not allowed in a workflow")
|
require.ErrorContains(t, err, "not allowed in a workflow")
|
||||||
|
|
||||||
// the runner reading its own files is what those options are for
|
// the runner reading its own files is what those options are for
|
||||||
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
||||||
_, _, err = cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
_, _, err = cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// a bare name is no longer resolved from the runner's environment, for either source
|
// a bare name is no longer resolved from the runner's environment, for either source
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
RunnerOptions: "--env RUNNER_SECRET",
|
RunnerOptions: "--env RUNNER_SECRET",
|
||||||
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
config, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
config, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
|
|
||||||
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
@@ -666,6 +701,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{})
|
||||||
@@ -674,9 +712,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
||||||
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *test.Hook) {
|
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
RunnerOptions: runnerOptions,
|
RunnerOptions: runnerOptions,
|
||||||
WorkflowOptions: workflowOptions,
|
WorkflowOptions: workflowOptions,
|
||||||
@@ -685,7 +723,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
|
|||||||
ValidVolumes: []string{"**"},
|
ValidVolumes: []string{"**"},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{
|
||||||
Privileged: privileged,
|
Privileged: privileged,
|
||||||
UsernsMode: container.UsernsMode("private"),
|
UsernsMode: container.UsernsMode("private"),
|
||||||
NetworkMode: container.NetworkMode("bridge"),
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
@@ -699,7 +737,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
|
||||||
@@ -726,8 +766,8 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
allowed := filepath.Join(base, "allowed")
|
allowed := filepath.Join(base, "allowed")
|
||||||
@@ -762,6 +802,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 := logtest.NewLogger()
|
||||||
|
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), 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.AllRecords(), 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{
|
||||||
@@ -841,8 +897,8 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
@@ -864,7 +920,7 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
||||||
warnings := func(runnerOptions, workflowOptions string) int {
|
warnings := func(runnerOptions, workflowOptions string) int {
|
||||||
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
||||||
return len(hook.AllEntries())
|
return len(hook.AllRecords())
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
||||||
@@ -873,8 +929,37 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|||||||
assert.Equal(t, 1, warnings("", "--privileged"))
|
assert.Equal(t, 1, warnings("", "--privileged"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
||||||
|
for _, entrypoint := range [][]string{nil, {"sleep", "3600"}} {
|
||||||
|
for _, optionEntrypoint := range []string{"/sbin/dumb-init", ""} {
|
||||||
|
logger, hook := logtest.NewLogger()
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
Entrypoint: entrypoint,
|
||||||
|
RunnerOptions: "--entrypoint=runner-entrypoint",
|
||||||
|
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
||||||
|
},
|
||||||
|
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
if len(entrypoint) > 0 {
|
||||||
|
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, []string{optionEntrypoint}, opts.Config.Entrypoint)
|
||||||
|
}
|
||||||
|
require.NotNil(t, opts.HostConfig.Init)
|
||||||
|
assert.True(t, *opts.HostConfig.Init)
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "created"}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, cr.create(nil, nil)(common.WithLogger(t.Context(), logger)))
|
||||||
|
assert.Equal(t, "created", cr.id)
|
||||||
|
assert.Equal(t, len(entrypoint) > 0, len(hook.AllRecords()) == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loaded 50 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user