mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 20:03:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8161c673 | ||
|
|
3f70822458 | ||
|
|
be90c01468 | ||
|
|
6c6a878403 | ||
|
|
f97680a68d | ||
|
|
dbd9a892f8 | ||
|
|
e178c03adc | ||
|
|
b66433e667 | ||
|
|
a8dcd5b67c | ||
|
|
da9b559fb5 | ||
|
|
9dd9204937 | ||
|
|
24c13a1fd0 | ||
|
|
4c2ab943a8 | ||
|
|
20497aaf4f | ||
|
|
09b643bc14 | ||
|
|
1d6c6ffef9 | ||
|
|
70387cca44 | ||
|
|
68547886a5 | ||
|
|
8700adc933 | ||
|
|
b70ff6893a | ||
|
|
3618385b28 | ||
|
|
55a625f733 | ||
|
|
47366f8f34 | ||
|
|
b7aeda6e7f | ||
|
|
aced51b4d5 | ||
|
|
34bfa19150 | ||
|
|
96d9f491db | ||
|
|
14ec00b66e | ||
|
|
68c6a5b4f1 | ||
|
|
0cd0e52a24 | ||
|
|
47d5b5ad03 | ||
|
|
2398d4a527 | ||
|
|
b7a3bf98bc | ||
|
|
da4037899a | ||
|
|
e4fe49dba4 | ||
|
|
41c72216bf | ||
|
|
3f7fd16ea1 | ||
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 |
No files matched your search
@@ -20,11 +20,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# Custom publishers (the R2 upload below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
# has already been created. Fail here instead, before anything
|
||||
# is built or published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
@@ -35,6 +34,7 @@ jobs:
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
@@ -42,11 +42,6 @@ jobs:
|
||||
args: release --nightly
|
||||
env:
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
@@ -85,7 +80,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
@@ -12,11 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# Custom publishers (the R2 upload below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
# has already been created. Fail here instead, before anything
|
||||
# is built or published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
@@ -27,6 +26,7 @@ jobs:
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||
@@ -41,11 +41,6 @@ jobs:
|
||||
args: release
|
||||
env:
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
@@ -88,7 +83,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
check-latest: true
|
||||
- name: prepare anonymous docker config
|
||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||
@@ -33,6 +34,8 @@ jobs:
|
||||
done
|
||||
- name: lint
|
||||
run: make lint
|
||||
- name: checks
|
||||
run: make checks
|
||||
- name: build
|
||||
run: make build
|
||||
- name: test
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.env
|
||||
!/act/runner/testdata/secrets/.env
|
||||
.runner
|
||||
.runner.lock
|
||||
coverage.txt
|
||||
.tmp/
|
||||
/config.yaml
|
||||
|
||||
+4
-4
@@ -11,6 +11,7 @@ linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- goheader
|
||||
@@ -36,12 +37,8 @@ linters:
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: io/ioutil
|
||||
desc: use os or io instead
|
||||
- pkg: golang.org/x/exp
|
||||
desc: it's experimental and unreliable
|
||||
- pkg: github.com/pkg/errors
|
||||
desc: use builtin errors package instead
|
||||
nolintlint:
|
||||
allow-unused: false
|
||||
require-explanation: true
|
||||
@@ -102,6 +99,9 @@ linters:
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: cmd
|
||||
- linters:
|
||||
- forcetypeassert
|
||||
path: _test\.go
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
+7
-19
@@ -83,24 +83,12 @@ builds:
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||
|
||||
blobs:
|
||||
-
|
||||
provider: s3
|
||||
bucket: "{{ .Env.S3_BUCKET }}"
|
||||
region: "{{ .Env.S3_REGION }}"
|
||||
directory: "gitea-runner/{{.Version}}"
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
|
||||
# isn't usable here since it authenticates from the global AWS_* env
|
||||
# with no per-entry credentials; `publishers:` supports per-entry
|
||||
# `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
|
||||
# artifact. Custom publishers inherit almost nothing from the
|
||||
# environment, hence the explicit R2_* forwarding below.
|
||||
#
|
||||
# This publisher fires 109 times for 73 distinct keys because
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
@@ -125,7 +113,7 @@ publishers:
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
- formats: [binary]
|
||||
name_template: "{{ .Binary }}"
|
||||
allow_different_binary_count: true
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
- Never assume, verify before claiming
|
||||
- Use `make help` to find available development targets
|
||||
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
||||
- Run `make tidy` after any `go.mod` changes
|
||||
- Run single go unit tests with `go test -run '^TestName$' ./modulepath/`
|
||||
- Add the current year into the copyright header of new `.go` files
|
||||
- Ensure no trailing whitespace in edited files
|
||||
- PR descriptions: minimal, only what and why, no task lists or file listings
|
||||
- Reference issues and PRs by full URL, not by number
|
||||
- Use Conventional Commits for commit messages and PR titles, plus the `enhance` type for user-facing enhancements
|
||||
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
|
||||
- Attribute agent authorship on one trailing line in issue and pull request comments, never as a PR description section
|
||||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
|
||||
- Include authorship attribution in issue and pull request comments
|
||||
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
||||
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||
- Add the current year into the copyright header of new `.go` files
|
||||
- Read `DEVELOPMENT.md` for internals and conventions
|
||||
- Ensure no trailing whitespace in edited files
|
||||
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
||||
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
||||
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
|
||||
- Run single go tests with `go test -run '^TestName$' ./modulepath/`. `make test` self-skips the integration tests without docker or network, `make test-dind` runs the daemon-facing tests against the built dind image
|
||||
- Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation
|
||||
- Wait on a deterministic condition rather than `sleep`
|
||||
- Update the files under `docs/` when behavior documented there changes
|
||||
@@ -0,0 +1,32 @@
|
||||
# Development
|
||||
|
||||
## Job log line format
|
||||
|
||||
Gitea stores one log row per line and its web UI decodes the payload, so getting the encoding
|
||||
wrong never fails a test here, it only shows up in the browser.
|
||||
|
||||
**A row cannot contain a real newline.** `FormatLog` rewrites `\n` to a literal backslash-n and
|
||||
truncates at 64 KiB on a byte boundary.
|
||||
|
||||
**The payload of a line starting with a recognised prefix is decoded**, with the escape set
|
||||
depending on the prefix:
|
||||
|
||||
| prefix | decodes |
|
||||
| --- | --- |
|
||||
| `##[error]` `##[warning]` `##[notice]` `##[debug]` `##[group]` `##[endgroup]` `##[add-matcher]` | `%25` `%0D` `%0A` `%3B` `%5D` |
|
||||
| `::error::` `::warning::` `::notice::` `::debug::` (with or without ` key=value` properties), `::group::` `::endgroup::` `::add-matcher::` | `%25` `%0D` `%0A` |
|
||||
| `##[command]` `[command]`, or no recognised prefix | nothing |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Emitting a command line?** Escape the payload with `runner.EscapeCommandData`. One escaper
|
||||
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
||||
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
||||
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
||||
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
|
||||
decoded once. Decoding here double-decodes and destroys multi-line.
|
||||
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
||||
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
|
||||
encoded forms too.
|
||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||
decodes exactly those two when folding a location into an annotation.
|
||||
+2
-2
@@ -17,7 +17,7 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.2-dind AS dind
|
||||
FROM docker:29.7.1-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||
FROM docker:29.7.1-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||
XGO_VERSION := go-1.26.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]0
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
@@ -18,8 +18,10 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected] # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
STATIC ?=
|
||||
EXTLDFLAGS ?=
|
||||
@@ -95,7 +97,7 @@ go-check:
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: fmt
|
||||
@diff=$$(git diff --color=always); \
|
||||
@diff=$$(git diff --color=always -- '*.go'); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
@@ -110,6 +112,9 @@ deps-tools: ## install tool dependencies
|
||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||
wait
|
||||
|
||||
.PHONY: checks
|
||||
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-go-windows ## lint everything
|
||||
|
||||
@@ -131,12 +136,17 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||
@node ./tools/lint-pr-title.ts
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: deps-tools
|
||||
security-check:
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
|
||||
.PHONY: tidy
|
||||
tidy: ## run go mod tidy
|
||||
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
|
||||
$(GO) mod tidy
|
||||
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
|
||||
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
|
||||
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
|
||||
fi
|
||||
|
||||
.PHONY: tidy-check
|
||||
tidy-check: tidy
|
||||
@@ -148,8 +158,8 @@ tidy-check: tidy
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: coverage-report
|
||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||
|
||||
@@ -129,27 +129,64 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a
|
||||
|
||||
### Configuration
|
||||
|
||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||
The runner reads a YAML file. Without one, every option keeps its default.
|
||||
|
||||
```bash
|
||||
./gitea-runner generate-config > config.yaml
|
||||
./gitea-runner config init # write config.yaml, with no option set
|
||||
./gitea-runner config generate | less # read what the options do
|
||||
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
|
||||
```
|
||||
|
||||
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
||||
`config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them.
|
||||
|
||||
#### Editing a config file
|
||||
|
||||
`config` edits a file in place, which is handy in provisioning scripts:
|
||||
|
||||
```bash
|
||||
./gitea-runner -c config.yaml register
|
||||
./gitea-runner -c config.yaml daemon
|
||||
./gitea-runner -c config.yaml cache-server
|
||||
./gitea-runner config set runner.capacity 4
|
||||
./gitea-runner config set runner.timeout 90m # written as 1h30m0s
|
||||
./gitea-runner config set runner.envs.MY_VAR value
|
||||
./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
|
||||
./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
|
||||
./gitea-runner config get runner.labels
|
||||
```
|
||||
|
||||
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
|
||||
A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values.
|
||||
|
||||
#### Without a config file
|
||||
An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped.
|
||||
|
||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
||||
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
||||
|
||||
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
#### Tool cache
|
||||
|
||||
Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`, which is `/opt/hostedtoolcache` inside a job. `runner.tool_cache_mode` selects what backs it:
|
||||
|
||||
| Mode | Tool cache | Trade-off |
|
||||
| --- | --- | --- |
|
||||
| `none` (default) | Per job, provided by the job image | A version the image lacks is downloaded in every job |
|
||||
| `shared` | One volume reused by every job | Two jobs writing the same tool version at once corrupt it, so use it only with `runner.capacity: 1` |
|
||||
|
||||
With `none`, tools must come from the job image. Install them into `/opt/hostedtoolcache/<tool>/<version>/<arch>`, with an empty `<arch>.complete` file next to the directory:
|
||||
|
||||
```dockerfile
|
||||
RUN GO=$(curl -fsSL 'https://go.dev/dl/?mode=json' | grep -oP '"version": "\Kgo1\.26\.[0-9]*' | head -1); \
|
||||
DIR="/opt/hostedtoolcache/go/${GO#go}/x64" && \
|
||||
mkdir -p "$(dirname "$DIR")" && \
|
||||
curl -fsSL "https://dl.google.com/go/${GO}.linux-amd64.tar.gz" | tar -xz -C /tmp && \
|
||||
mv /tmp/go "$DIR" && \
|
||||
touch "${DIR}.complete"
|
||||
```
|
||||
|
||||
A workflow requesting a minor version, `go-version: "1.26"`, resolves to the newest matching version in the cache, so a patch update in the image still hits it.
|
||||
|
||||
Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavour is the one that ships tools in this layout.
|
||||
|
||||
`gitea-runner exec` reads no config file and takes `--tool-cache-mode` instead, defaulting to `none`.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
|
||||
### Labels
|
||||
|
||||
@@ -209,10 +246,76 @@ Whenever the resulting labels differ from the ones in the registration file, the
|
||||
|
||||
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||
|
||||
#### Service containers
|
||||
|
||||
A job's `services` are started before its steps run. When a service's image or its `options` declare a healthcheck, the runner waits for it to report healthy, so a workflow does not have to poll for its own services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-retries 10
|
||||
```
|
||||
|
||||
A service that reports unhealthy fails the job right away, with its container log. One that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait). A service that exits without declaring a healthcheck only gets its log and a warning.
|
||||
|
||||
A job in a container reaches a service by its id on the job network, on the port the service listens on, for example `psql -h postgres -p 5432`. The started containers also fill the `job` context: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`, where `ports` maps a container port to the host port Docker published it on, for the services that publish one.
|
||||
|
||||
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||
|
||||
#### Proxy
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
|
||||
```sh
|
||||
http_proxy=http://proxy.example:3128
|
||||
https_proxy=http://proxy.example:3128
|
||||
no_proxy=gitea.internal,.example.local
|
||||
```
|
||||
|
||||
The runner uses them for its own requests and gives them to every job, in lower and upper case.
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
|
||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||
|
||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
|
||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||
|
||||
Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network.
|
||||
|
||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||
|
||||
#### Caching (`actions/cache`)
|
||||
|
||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
|
||||
**Eviction**
|
||||
|
||||
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled.
|
||||
|
||||
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
|
||||
|
||||
**Cache service v2**
|
||||
|
||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
v2: false
|
||||
```
|
||||
|
||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
||||
|
||||
**Shared cache across multiple runners**
|
||||
|
||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
@@ -242,6 +345,8 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
Jobs reach the cache server at `external_server`, so when a reverse proxy fronts the server, point `external_server` at the proxy. The cache server itself needs no extra configuration.
|
||||
|
||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||
|
||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||
@@ -254,11 +359,12 @@ Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image en
|
||||
|
||||
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
||||
|
||||
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
|
||||
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
|
||||
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)
|
||||
- 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
|
||||
- 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
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||
|
||||
#### Post-task script (`runner.post_task_script`)
|
||||
|
||||
@@ -270,6 +376,16 @@ On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not su
|
||||
|
||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||
|
||||
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||
|
||||
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||
|
||||
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||
|
||||
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||
|
||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||
|
||||
### Example Deployments
|
||||
|
||||
Check out the [examples](examples) directory for sample deployment types.
|
||||
+411
-140
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,274 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
// The cache service v2 API. A client on this version talks twirp to
|
||||
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||
// Both API versions are served from the same store, so a repository keeps its cache
|
||||
// when a workflow moves between action versions.
|
||||
//
|
||||
// Responses carry the proto field names, which is what Gitea's own results API emits and the only
|
||||
// spelling the Go clients parse. The JavaScript toolkit accepts either.
|
||||
const (
|
||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||
|
||||
// blobPath authenticates by signature, because the client uploads without an
|
||||
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||
blobPath = apiPath + "/blobs"
|
||||
|
||||
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||
blobUploadPurpose = "upload:"
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||
router.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
|
||||
// the upload.
|
||||
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||
// existing and silently never saved.
|
||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
} else if existing != nil {
|
||||
h.touch(db, existing) // the client skips the upload, so this is the only sign the entry is still in use
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
}
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
// int64 fields travel as strings in the proto JSON mapping.
|
||||
"entry_id": strconv.FormatUint(cache.ID, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
"matched_key": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// in order.
|
||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.touchCache(id, true); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
switch strings.ToLower(query.Get("comp")) {
|
||||
case "block":
|
||||
blockID := query.Get("blockid")
|
||||
if blockID == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||
return
|
||||
}
|
||||
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||
case "blocklist":
|
||||
var list struct{ Latest []string }
|
||||
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||
return
|
||||
}
|
||||
err = h.storage.OrderBlocks(id, list.Latest)
|
||||
default:
|
||||
err = h.storage.Write(id, 0, r.Body)
|
||||
}
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The Azure SDK client dereferences this without checking, so its absence panics the caller.
|
||||
w.Header().Set("x-ms-request-id", strconv.FormatInt(time.Now().UnixNano(), 10))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||
}
|
||||
|
||||
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||
// instead of a parse error.
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||
type (
|
||||
v2CreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
v2FinalizeRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes json.Number `json:"size_bytes"`
|
||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
v2DownloadRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
RestoreKeys []string `json:"restore_keys"`
|
||||
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||
}
|
||||
)
|
||||
|
||||
func (d v2DownloadRequest) keys() []string {
|
||||
restoreKeys := d.RestoreKeys
|
||||
if len(restoreKeys) == 0 {
|
||||
restoreKeys = d.RestoreKeysCamel
|
||||
}
|
||||
return append([]string{d.Key}, restoreKeys...)
|
||||
}
|
||||
|
||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||
var req T
|
||||
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||
return req, err
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// v2Call posts a twirp request to the cache service and returns the decoded response.
|
||||
// Field names are the proto ones, which is what the toolkit's client sends.
|
||||
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
return got
|
||||
}
|
||||
|
||||
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
|
||||
func putBlob(t *testing.T, url string, content []byte) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
// The Azure SDK client dereferences this header without checking, so a blob upload that
|
||||
// omits it panics the caller rather than failing it.
|
||||
require.NotEmpty(t, resp.Header.Get("x-ms-request-id"))
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getURL(t *testing.T, url string) []byte {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||
// with the upload URL it used.
|
||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||
t.Helper()
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||
require.Equal(t, true, created["ok"])
|
||||
uploadURL, _ = created["signed_upload_url"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||
|
||||
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": key, "version": version,
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
}), uploadURL
|
||||
}
|
||||
|
||||
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
|
||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||
// or to replace a finalized entry.
|
||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||
handler := newTestHandler(t, Policy{})
|
||||
content := []byte("the cached archive")
|
||||
|
||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||
|
||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
assert.NotEmpty(t, finalized["entry_id"])
|
||||
|
||||
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||
// and it is an upload URL only: nothing reads a blob back through it.
|
||||
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
|
||||
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-v1", got["matched_key"])
|
||||
downloadURL, _ := got["signed_download_url"].(string)
|
||||
require.NotEmpty(t, downloadURL)
|
||||
assert.Equal(t, content, getURL(t, downloadURL))
|
||||
}
|
||||
|
||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
handler := newTestHandler(t, Policy{})
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||
uploadURL, _ := created["signed_upload_url"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
|
||||
blocks := map[string][]byte{}
|
||||
var order []string
|
||||
for i, part := range []string{"hello ", "world", "!"} {
|
||||
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||
blocks[blockID] = []byte(part)
|
||||
order = append(order, blockID)
|
||||
}
|
||||
// Upload in an order that is not the block list order.
|
||||
for _, blockID := range []string{order[2], order[0], order[1]} {
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||
}
|
||||
|
||||
var list bytes.Buffer
|
||||
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||
for _, blockID := range order {
|
||||
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||
}
|
||||
list.WriteString(`</BlockList>`)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||
|
||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
})
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "hello world!", string(getURL(t, got["signed_download_url"].(string))))
|
||||
}
|
||||
|
||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
handler := newTestHandler(t, Policy{})
|
||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||
require.Equal(t, true, saved["ok"])
|
||||
|
||||
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The toolkit serialises with the proto field names; the camelCase spellings of the same
|
||||
// proto JSON mapping are accepted alongside them.
|
||||
for _, field := range []string{"restore_keys", "restoreKeys"} {
|
||||
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||
})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-abc", got["matched_key"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, again["ok"])
|
||||
})
|
||||
|
||||
// A key that is only a prefix of an existing one is a different entry, so the
|
||||
// reservation check must be exact and not a restore-key prefix match, or the shorter
|
||||
// key would be reported as existing and silently never saved.
|
||||
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||
require.Equal(t, true, reserved["ok"])
|
||||
assert.NotEmpty(t, reserved["signed_upload_url"])
|
||||
})
|
||||
|
||||
t.Run("a proxied job is handed the address its runner registered", func(t *testing.T) {
|
||||
const proxy = "https://cache.example.invalid"
|
||||
handler.RegisterJob("proxied", JobCredential{Repo: testRepo, PublicURL: proxy + "/"})
|
||||
client := &http.Client{Transport: &bearerTransport{token: "proxied"}}
|
||||
|
||||
created := v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "proxied-key", "version": "v1"})
|
||||
assert.True(t, strings.HasPrefix(created["signed_upload_url"].(string), proxy+blobPath+"/"))
|
||||
|
||||
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.True(t, strings.HasPrefix(got["signed_download_url"].(string), proxy+apiPath+"/artifacts/"))
|
||||
})
|
||||
|
||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// The size the client declares is what Commit validates the assembled archive against.
|
||||
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
|
||||
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||
})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
|
||||
// Both API versions are served from one store, so an entry written through v2 is a hit for
|
||||
// a v1 client asking for the same key and version.
|
||||
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||
assert.NotEmpty(t, got["archiveLocation"])
|
||||
})
|
||||
|
||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
|
||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||
|
||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The results service is one origin serving every github.actions.results.api.v1 service, and
|
||||
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
||||
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
||||
// this runner cannot patch need, docker buildx among them.
|
||||
//
|
||||
// The instance to forward to travels with the job registration rather than with configuration, so
|
||||
// a cache server shared between runners serves each of their instances.
|
||||
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
||||
|
||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||
// registered with, and the 404 the router would have written otherwise.
|
||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||
if err != nil {
|
||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(target)
|
||||
// Gitea builds the URLs it hands back from this Host, and their scheme from the
|
||||
// connection unless a forwarded header overrides it, so artifact bodies go to Gitea
|
||||
// directly and never through here.
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
if cred.InsecureTLS {
|
||||
proxy.Transport = insecureTransport
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
||||
// still point at Gitea, and nothing else is proxied.
|
||||
func TestFrontResultsService(t *testing.T) {
|
||||
var gotHost, gotPath, gotProto string
|
||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto")
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer gitea.Close()
|
||||
|
||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
const token = "forward-token"
|
||||
|
||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||
post := func(path string) int {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||
"an unregistered token is forwarded nowhere")
|
||||
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
||||
|
||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||
|
||||
gotPath = ""
|
||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||
}
|
||||
@@ -5,12 +5,15 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
return s.writeFile(s.tempName(id, offset), reader)
|
||||
}
|
||||
|
||||
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||
return s.writeFile(s.blockName(id, blockID), reader)
|
||||
}
|
||||
|
||||
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
for i, blockID := range blockIDs {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := assemble(name, tempNames)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||
func assemble(name string, tempNames []string) (int64, error) {
|
||||
if len(tempNames) == 1 {
|
||||
info, err := os.Stat(tempNames[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), os.Rename(tempNames[0], name)
|
||||
}
|
||||
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -102,9 +143,13 @@ func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||
http.ServeFile(w, r, name)
|
||||
}
|
||||
|
||||
func (s *Storage) Remove(id uint64) {
|
||||
_ = os.Remove(s.filename(id))
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
|
||||
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
|
||||
func (s *Storage) Remove(id uint64) error {
|
||||
if err := os.Remove(s.filename(id)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(s.tempDir(id))
|
||||
}
|
||||
|
||||
func (s *Storage) filename(id uint64) string {
|
||||
@@ -119,6 +164,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||
// Commit's name-ordered concatenation.
|
||||
const blockFilePrefix = "block-"
|
||||
|
||||
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||
// path element.
|
||||
sum := sha256.Sum256([]byte(blockID))
|
||||
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
@@ -127,7 +183,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import "slices"
|
||||
|
||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||
listNames := make([]string, 0)
|
||||
lists := make([][]any, 0)
|
||||
for k, v := range mapOfLists {
|
||||
listNames = append(listNames, k)
|
||||
lists = append(lists, v)
|
||||
}
|
||||
|
||||
listCart := cartN(lists...)
|
||||
|
||||
rtn := make([]map[string]any, 0)
|
||||
for _, list := range listCart {
|
||||
vMap := make(map[string]any)
|
||||
for i, v := range list {
|
||||
vMap[listNames[i]] = v
|
||||
}
|
||||
rtn = append(rtn, vMap)
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func cartN(a ...[]any) [][]any {
|
||||
c := 1
|
||||
for _, a := range a {
|
||||
c *= len(a)
|
||||
}
|
||||
if c == 0 || len(a) == 0 {
|
||||
return nil
|
||||
}
|
||||
p := make([][]any, c)
|
||||
b := make([]any, c*len(a))
|
||||
n := make([]int, len(a))
|
||||
s := 0
|
||||
for i := range p {
|
||||
e := s + len(a)
|
||||
pi := b[s:e]
|
||||
p[i] = pi
|
||||
s = e
|
||||
for j, n := range n {
|
||||
pi[j] = a[j][n]
|
||||
}
|
||||
for j := range slices.Backward(n) {
|
||||
n[j]++
|
||||
if n[j] < len(a[j]) {
|
||||
break
|
||||
}
|
||||
n[j] = 0
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCartesianProduct(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
input := map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {"a", "b", "c"},
|
||||
"baz": {false, true},
|
||||
}
|
||||
|
||||
output := CartesianProduct(input)
|
||||
assert.Len(output, 24)
|
||||
|
||||
for _, v := range output {
|
||||
assert.Len(v, 3)
|
||||
|
||||
assert.Contains(v, "foo")
|
||||
assert.Contains(v, "bar")
|
||||
assert.Contains(v, "baz")
|
||||
}
|
||||
|
||||
input = map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {},
|
||||
"baz": {false, true},
|
||||
}
|
||||
output = CartesianProduct(input)
|
||||
assert.Empty(output)
|
||||
|
||||
input = map[string][]any{}
|
||||
output = CartesianProduct(input)
|
||||
assert.Empty(output)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
||||
func TestMaxParallel2Quick(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
|
||||
executors := make([]Executor, 4)
|
||||
for i := range 4 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
||||
"Should not exceed max-parallel: 2")
|
||||
}
|
||||
|
||||
// Test that verifies max-parallel: 1 enforces sequential execution
|
||||
func TestMaxParallel1Sequential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
var executionOrder []int
|
||||
var orderMutex sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track execution order
|
||||
orderMutex.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
orderMutex.Unlock()
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
||||
"max-parallel: 1 should only run 1 task at a time")
|
||||
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
||||
func TestMaxParallelJobExecution(t *testing.T) {
|
||||
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
var executionOrder []int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track max concurrent
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
||||
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
||||
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
||||
var maxConcurrent int32
|
||||
var currentRunning atomic.Int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// When maxParallel is 0 or negative, it defaults to 1
|
||||
err := NewParallelExecutor(0, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelWithErrors tests error handling with max-parallel
|
||||
func TestMaxParallelWithErrors(t *testing.T) {
|
||||
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
||||
var successCount int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
if taskID == 2 {
|
||||
return assert.AnError
|
||||
}
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
// Should return the error from task 2
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Other tasks should still execute
|
||||
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
||||
})
|
||||
|
||||
t.Run("ContextCancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var startedCount int32
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
atomic.AddInt32(&startedCount, 1)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel after a short delay
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Not all tasks should start due to cancellation (but timing may vary)
|
||||
// Just verify cancellation occurred
|
||||
t.Logf("Started %d tasks before cancellation", startedCount)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||
var sharedCounter int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 100)
|
||||
for i := range 100 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
mu.Lock()
|
||||
sharedCounter++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(10, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
||||
})
|
||||
|
||||
t.Run("ChannelCommunication", func(t *testing.T) {
|
||||
resultChan := make(chan int, 50)
|
||||
|
||||
executors := make([]Executor, 50)
|
||||
for i := range 50 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
resultChan <- taskID
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(5, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
close(resultChan)
|
||||
|
||||
results := make(map[int]bool)
|
||||
for result := range resultChan {
|
||||
results[result] = true
|
||||
}
|
||||
|
||||
assert.Len(t, results, 50, "All task IDs should be received")
|
||||
})
|
||||
}
|
||||
+48
-30
@@ -9,9 +9,9 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -82,44 +82,45 @@ func TestNewConditionalExecutor(t *testing.T) {
|
||||
assert.Equal(1, falseCount)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||
// find the gate already open so the last one still finishes with no partner left.
|
||||
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
|
||||
var counted, active, peak atomic.Int32
|
||||
var once sync.Once
|
||||
reached := make(chan struct{})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var count, activeCount, maxCount atomic.Int32
|
||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
|
||||
active := activeCount.Add(1)
|
||||
return func(ctx context.Context) error {
|
||||
counted.Add(1)
|
||||
running := active.Add(1)
|
||||
for {
|
||||
m := maxCount.Load()
|
||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
||||
seen := peak.Load()
|
||||
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
activeCount.Add(-1)
|
||||
|
||||
if running >= wantActive {
|
||||
once.Do(func() { close(reached) })
|
||||
}
|
||||
<-reached
|
||||
active.Add(-1)
|
||||
return nil
|
||||
})
|
||||
}, &counted, &peak
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
exec, count, maxActive := concurrencyProbe(2)
|
||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||
|
||||
// Reset to test running the executor with 0 parallelism
|
||||
count.Store(0)
|
||||
activeCount.Store(0)
|
||||
maxCount.Store(0)
|
||||
|
||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||
assert.NoError(errSingle)
|
||||
// parallelism below 1 falls back to a single worker
|
||||
exec, count, maxActive = concurrencyProbe(1)
|
||||
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||
@@ -173,6 +174,23 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
||||
var successCount atomic.Int32
|
||||
executors := make([]Executor, 5)
|
||||
for i := range executors {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
if i == 2 {
|
||||
return errors.New("fake error")
|
||||
}
|
||||
successCount.Add(1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
||||
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||
}
|
||||
|
||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var calls []string
|
||||
|
||||
+18
-7
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
@@ -32,7 +33,7 @@ var (
|
||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||
|
||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
@@ -43,10 +44,7 @@ var (
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||
func AcquireCloneLock(dir string) func() {
|
||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
return cloneLocks.Lock(dir)
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
@@ -347,6 +345,16 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
||||
return fetchOptions, pullOptions
|
||||
}
|
||||
|
||||
// staleRefreshErr reports why a failed refresh must abort: the resolve and
|
||||
// checkout that follow are local and succeed on a cancelled context, which
|
||||
// would hand back the cached revision as if it were fresh.
|
||||
func staleRefreshErr(ctx context.Context, err error) error {
|
||||
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// NewGitCloneExecutor creates an executor to clone git repos
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -387,7 +395,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
err = r.Fetch(&fetchOptions)
|
||||
err = r.FetchContext(ctx, &fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return err
|
||||
}
|
||||
@@ -456,9 +464,12 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
switch {
|
||||
case !isOfflineMode && !shallow:
|
||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||
}
|
||||
if err := staleRefreshErr(ctx, err); err != nil {
|
||||
return err
|
||||
}
|
||||
case isOfflineMode && reused:
|
||||
reusedMsg = " (reused in offline mode)"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -19,6 +22,8 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -610,12 +615,56 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
t.Fatal("acquire on a different directory must not block")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
||||
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
reached := make(chan struct{})
|
||||
var once sync.Once
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
once.Do(func() { close(reached) })
|
||||
<-block
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
close(block)
|
||||
server.Close()
|
||||
})
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
||||
}()
|
||||
|
||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
require.Same(t, v1, v2)
|
||||
})
|
||||
select {
|
||||
case <-reached:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the executor never reached the remote")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("fetch ignored context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleRefreshErr(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
require.NoError(t, staleRefreshErr(ctx, errors.New("remote hung up")))
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, staleRefreshErr(ctx, errors.New("remote hung up")), context.Canceled)
|
||||
require.NoError(t, staleRefreshErr(ctx, gogit.NoErrAlreadyUpToDate))
|
||||
}
|
||||
@@ -19,7 +19,9 @@ func GetOutboundIP() net.IP {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP
|
||||
}
|
||||
}
|
||||
|
||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||
|
||||
@@ -6,12 +6,14 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
)
|
||||
|
||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||
@@ -57,6 +59,32 @@ type FileEntry struct {
|
||||
Body string
|
||||
}
|
||||
|
||||
// Container and healthcheck states, as plain strings so a caller of Info needs no docker
|
||||
// SDK of its own.
|
||||
const (
|
||||
StateRunning = string(container.StateRunning)
|
||||
|
||||
HealthNone = string(container.NoHealthcheck)
|
||||
HealthStarting = string(container.Starting)
|
||||
HealthHealthy = string(container.Healthy)
|
||||
HealthUnhealthy = string(container.Unhealthy)
|
||||
)
|
||||
|
||||
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
||||
// fragment, missingContainerError composes it into the message every operation shares.
|
||||
var ErrContainerNotFound = errors.New("does not exist")
|
||||
|
||||
// Info is a snapshot of a container, as of one inspect.
|
||||
type Info struct {
|
||||
ID string
|
||||
State string // the docker container state: "created", "running", "exited", ...
|
||||
ExitCode int
|
||||
Health string // one of the Health* constants
|
||||
// HealthOutput is the last healthcheck probe's output.
|
||||
HealthOutput string
|
||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||
}
|
||||
|
||||
// Container for managing docker run containers
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
@@ -65,6 +93,8 @@ type Container interface {
|
||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
DumpLogs(ctx context.Context) error
|
||||
Pull(forcePull bool) common.Executor
|
||||
Start(attach bool) common.Executor
|
||||
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||
@@ -82,12 +112,14 @@ type NewDockerBuildExecutorInput struct {
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
BuildArgs map[string]*string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||
type NewDockerNetworkCreateExecutorInput struct {
|
||||
EnableIPv4 *bool
|
||||
EnableIPv6 *bool
|
||||
RunnerUUID string
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
|
||||
@@ -49,6 +49,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
Remove: true,
|
||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||
Dockerfile: input.Dockerfile,
|
||||
BuildArgs: input.BuildArgs,
|
||||
}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
|
||||
+15
-11
@@ -4,8 +4,10 @@
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
// This file is exact copy of https://github.com/docker/cli/blob/9a471180cb7d39c236d090399a9d362c3f5a8ebd/cli/command/container/opts.go
|
||||
// appended with license information.
|
||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
||||
// * appended with license information
|
||||
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||
//
|
||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||
// See DOCKER_LICENSE for the full license text.
|
||||
@@ -30,6 +32,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/docker/go-connections/nat"
|
||||
@@ -380,7 +383,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
var binds []string
|
||||
volumes := copts.volumes.GetMap()
|
||||
// add any bind targets to the list of container volumes
|
||||
for bind := range copts.volumes.GetMap() {
|
||||
for bind := range volumes {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -515,13 +518,13 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
// collect all the environment variables for the container
|
||||
envVariables, err := opts.ReadKVEnvStrings(copts.envFile.GetSlice(), copts.env.GetSlice())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("--env-file: %w", err)
|
||||
}
|
||||
|
||||
// collect all the labels for the container
|
||||
labels, err := opts.ReadKVStrings(copts.labelsFile.GetSlice(), copts.labels.GetSlice())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("--label-file: %w", err)
|
||||
}
|
||||
|
||||
pidMode := container.PidMode(copts.pidMode)
|
||||
@@ -1164,16 +1167,17 @@ func toNetipAddrSlice(ips []string) []netip.Addr {
|
||||
}
|
||||
|
||||
// invalidParameter wraps an error to indicate it was caused by invalid input.
|
||||
// This is a local replacement for docker/docker/errdefs.InvalidParameter.
|
||||
type invalidParameterError struct{ error }
|
||||
// This is a copy of docker/cli's cli/command/container/errors.go, which is not importable.
|
||||
type invalidParameterErr struct{ error }
|
||||
|
||||
func (e invalidParameterError) InvalidParameter() {}
|
||||
func (invalidParameterErr) InvalidParameter() {}
|
||||
func (e invalidParameterErr) Unwrap() error { return e.error }
|
||||
|
||||
func invalidParameter(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
if err == nil || cerrdefs.IsInvalidArgument(err) {
|
||||
return err
|
||||
}
|
||||
return invalidParameterError{err}
|
||||
return invalidParameterErr{err}
|
||||
}
|
||||
|
||||
func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
||||
|
||||
+339
-181
File diff suppressed because it is too large.
Load diff
@@ -51,7 +51,8 @@ func TestCreateFlagsValidate(t *testing.T) {
|
||||
|
||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||
cr := NewContainer(input).(*containerReference)
|
||||
cr, ok := NewContainer(input).(*containerReference)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
||||
|
||||
if msg.ErrorDetail.Message != "" {
|
||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||
return errors.New(msg.Error)
|
||||
return errors.New(msg.ErrorDetail.Message)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
|
||||
@@ -8,12 +8,69 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
const (
|
||||
networkCreateAttempts = 3
|
||||
networkCreateRetryDelay = time.Second
|
||||
|
||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||
// those of another runner sharing the daemon
|
||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
)
|
||||
|
||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||
}
|
||||
|
||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
// the emptiness check, not the label, is what keeps a live job of another process
|
||||
// sharing this registration safe
|
||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||
continue
|
||||
}
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
@@ -36,18 +93,45 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
if runnerUUID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||
}
|
||||
|
||||
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||
// malformed request, so the message is the only discriminator.
|
||||
func isAddressPoolExhausted(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
@@ -66,6 +150,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
@@ -73,16 +158,17 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Network.Containers) == 0 {
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
common.Logger(ctx).Debugf("%v", 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
|
||||
}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||
// test on its own, since testify has no expectation to match it against.
|
||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||
{Network: network.Network{ID: "orphan"}},
|
||||
{Network: network.Network{ID: "busy"}},
|
||||
{Network: network.Network{ID: "starting"}},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
@@ -30,22 +30,18 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return nil
|
||||
}
|
||||
|
||||
pull := input.ForcePull
|
||||
if !pull {
|
||||
// skip the pull when the image is already here: either none was forced, or a digest
|
||||
// pins the content so a forced pull could only fetch the same bytes again
|
||||
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||
logger.Debugf("Image exists? %v", imageExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||
}
|
||||
|
||||
if !imageExists {
|
||||
pull = true
|
||||
}
|
||||
}
|
||||
|
||||
if !pull {
|
||||
if imageExists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
imageRef := cleanImage(ctx, input.Image)
|
||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||
@@ -61,23 +57,33 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
// the daemon reports a failure that happens after the first progress line in the
|
||||
// stream rather than on the call itself, so both have to be checked
|
||||
pullOnce := func(opts client.ImagePullOptions) error {
|
||||
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
return err
|
||||
}
|
||||
return streamErr
|
||||
}
|
||||
|
||||
err = pullOnce(imagePullOptions)
|
||||
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
err = pullOnce(imagePullOptions)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// a registry that is down should not fail a job whose image is already here
|
||||
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
}
|
||||
|
||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||
@@ -122,6 +128,15 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
|
||||
func isPinnedImage(image string) bool {
|
||||
ref, err := reference.ParseAnyReference(image)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, pinned := ref.(reference.Canonical)
|
||||
return pinned
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, imageName string) string {
|
||||
ref, err := reference.ParseAnyReference(imageName)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,11 +6,15 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -65,3 +69,21 @@ func TestGetImagePullOptions(t *testing.T) {
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||
}
|
||||
|
||||
// A digest-pinned image is immutable, so its local copy is always current.
|
||||
func TestIsPinnedImage(t *testing.T) {
|
||||
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||
assert.False(t, isPinnedImage("alpine:latest"))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestLogDockerResponseError(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
streamErr := func(line string) error {
|
||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||
}
|
||||
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||
}
|
||||
+287
-80
@@ -26,7 +26,6 @@ import (
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/Masterminds/semver"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
@@ -41,7 +40,9 @@ import (
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"github.com/moby/moby/api/types/system"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/moby/moby/client/pkg/versions"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||
@@ -87,22 +88,14 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
|
||||
}
|
||||
}
|
||||
|
||||
// supportsContainerImagePlatform returns true if the underlying Docker server
|
||||
// API version is 1.41 and beyond
|
||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
||||
logger := common.Logger(ctx)
|
||||
// supportsContainerImagePlatform reports whether the Docker server API version
|
||||
// is 1.41 and beyond
|
||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
|
||||
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
||||
if err != nil {
|
||||
logger.Panicf("Failed to get Docker API Version: %s", err)
|
||||
return false
|
||||
return false, fmt.Errorf("get docker API version: %w", err)
|
||||
}
|
||||
sv, err := semver.NewVersion(ver.APIVersion)
|
||||
if err != nil {
|
||||
logger.Panicf("Failed to unmarshal Docker Version: %s", err)
|
||||
return false
|
||||
}
|
||||
constraint, _ := semver.NewConstraint(">= 1.41")
|
||||
return constraint.Check(sv)
|
||||
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41"), nil
|
||||
}
|
||||
|
||||
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
||||
@@ -204,6 +197,109 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
// Inspect resolves the container by name when its id is not known yet. One the daemon no
|
||||
// longer knows is reported as ErrContainerNotFound.
|
||||
func (cr *containerReference) Inspect(ctx context.Context) (*Info, error) {
|
||||
if common.Dryrun(ctx) {
|
||||
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
if err := cr.connect()(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cr.id == "" { // a known id is trusted, find() would spend a call validating it
|
||||
if err := cr.find()(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if cr.id == "" {
|
||||
return nil, cr.missingContainerError("inspect it")
|
||||
}
|
||||
|
||||
result, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return nil, cr.missingContainerError("inspect it")
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return containerInfoFromInspect(result.Container), nil
|
||||
}
|
||||
|
||||
// DumpLogs copies the container's log so far to its output writers.
|
||||
func (cr *containerReference) DumpLogs(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
if err := cr.connect()(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("read its logs")
|
||||
}
|
||||
|
||||
logs, err := cr.cli.ContainerLogs(ctx, cr.id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logs.Close()
|
||||
return cr.copyOutput(logs)
|
||||
}
|
||||
|
||||
// copyOutput writes a container stream to the writers the container was created with,
|
||||
// demultiplexing it unless the container has a TTY, which sends a single raw stream.
|
||||
func (cr *containerReference) copyOutput(stream io.Reader) error {
|
||||
outWriter := cr.input.Stdout
|
||||
if outWriter == nil {
|
||||
outWriter = os.Stdout
|
||||
}
|
||||
errWriter := cr.input.Stderr
|
||||
if errWriter == nil {
|
||||
errWriter = os.Stderr
|
||||
}
|
||||
|
||||
var err error
|
||||
if !cr.input.AllocatePTY || os.Getenv("NORAW") != "" {
|
||||
_, err = stdcopy.StdCopy(outWriter, errWriter, stream)
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, stream)
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line so the final line of
|
||||
// the output is not lost when it is not newline-terminated.
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
return err
|
||||
}
|
||||
|
||||
func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
||||
info := &Info{
|
||||
ID: inspect.ID,
|
||||
Health: HealthNone,
|
||||
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
||||
}
|
||||
|
||||
if state := inspect.State; state != nil {
|
||||
info.State = string(state.Status)
|
||||
info.ExitCode = state.ExitCode
|
||||
if health := state.Health; health != nil {
|
||||
info.Health = string(health.Status)
|
||||
if len(health.Log) > 0 {
|
||||
info.HealthOutput = strings.TrimSpace(health.Log[len(health.Log)-1].Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settings := inspect.NetworkSettings; settings != nil {
|
||||
for port, bindings := range settings.Ports {
|
||||
for _, binding := range bindings { // the last binding wins, a port maps to one host port
|
||||
if binding.HostPort != "" {
|
||||
info.Ports[port.Port()] = binding.HostPort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun)
|
||||
}
|
||||
@@ -349,10 +445,10 @@ func (cr *containerReference) Close() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// missingContainerError is the shared "container X does not exist" error
|
||||
// used by ops that need a live cr.id.
|
||||
// missingContainerError is the shared "container X does not exist" error used by ops that
|
||||
// need a live cr.id, wrapping ErrContainerNotFound so a caller can tell it from a failing daemon.
|
||||
func (cr *containerReference) missingContainerError(format string, args ...any) error {
|
||||
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
|
||||
return fmt.Errorf("container %q %w; cannot "+format, append([]any{cr.input.Name, ErrContainerNotFound}, args...)...)
|
||||
}
|
||||
|
||||
func (cr *containerReference) find() common.Executor {
|
||||
@@ -386,32 +482,71 @@ func (cr *containerReference) find() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict).
|
||||
func isContainerGone(err error) bool {
|
||||
return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err)
|
||||
}
|
||||
|
||||
func (cr *containerReference) remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
idOrName := cr.id
|
||||
if idOrName == "" && cr.input != nil {
|
||||
idOrName = cr.input.Name
|
||||
}
|
||||
if idOrName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
_, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
|
||||
// Kill first so removal never waits out a daemon's stop timeout: Docker kills outright
|
||||
// on a forced remove, Podman sends SIGTERM and waits. Only worth it for a container
|
||||
// this started, and removal can still deal with one it could not kill.
|
||||
if cr.id != "" {
|
||||
_, err := cr.cli.ContainerKill(ctx, cr.id, client.ContainerKillOptions{Signal: "SIGKILL"})
|
||||
if err != nil && !cerrdefs.IsConflict(err) && !cerrdefs.IsNotFound(err) {
|
||||
logger.Debugf("Container %s could not be killed: %v", cr.id, err)
|
||||
}
|
||||
}
|
||||
_, err := cr.cli.ContainerRemove(ctx, idOrName, client.ContainerRemoveOptions{
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil && !isContainerGone(err) {
|
||||
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
||||
switch {
|
||||
case cerrdefs.IsConflict(err):
|
||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
||||
// references and the network endpoint only once it finishes
|
||||
cr.waitForRemoval(ctx, idOrName)
|
||||
case err != nil && !cerrdefs.IsNotFound(err):
|
||||
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
||||
return nil // keep the id, the container is still there for a later Remove()
|
||||
}
|
||||
|
||||
logger.Debugf("Removed container: %v", cr.id)
|
||||
logger.Debugf("Removed container: %v", idOrName)
|
||||
cr.id = ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
||||
// per container, against the one minute the post-job executor allows for the whole
|
||||
// cleanup, so a job with several services can spend most of that budget here
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
waitResult := cr.cli.ContainerWait(ctx, idOrName, client.ContainerWaitOptions{
|
||||
Condition: container.WaitConditionRemoved,
|
||||
})
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
case <-ctx.Done():
|
||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
||||
// behind or its goroutine parks on the send for the lifetime of the process
|
||||
go func() {
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
}
|
||||
}()
|
||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
||||
logger := common.Logger(ctx)
|
||||
input := cr.input
|
||||
@@ -461,6 +596,16 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// When privileged mode is disabled, container.options is workflow-controlled
|
||||
// untrusted input. Strip the HostConfig fields that would let a workflow break
|
||||
// out of the container (host namespaces, capability expansion, security profile
|
||||
// overrides, device and runtime access). Otherwise these survive into the final
|
||||
// HostConfig even though --privileged is forced off.
|
||||
if !hostConfig.Privileged {
|
||||
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
|
||||
}
|
||||
|
||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||
|
||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
||||
@@ -471,8 +616,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
|
||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||
|
||||
hostConfig.Binds = append(hostConfig.Binds, containerConfig.HostConfig.Binds...)
|
||||
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
|
||||
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||
binds := hostConfig.Binds
|
||||
mounts := hostConfig.Mounts
|
||||
networkMode := hostConfig.NetworkMode
|
||||
@@ -538,11 +682,17 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
||||
}
|
||||
|
||||
var platSpecs *specs.Platform
|
||||
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
|
||||
platSpecs, err = parsePlatform(cr.input.Platform)
|
||||
if cr.input.Platform != "" {
|
||||
// Dropping the platform silently would build for the host arch.
|
||||
supported, err := supportsContainerImagePlatform(ctx, cr.cli)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if supported {
|
||||
if platSpecs, err = parsePlatform(cr.input.Platform); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
@@ -695,7 +845,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
err = cr.waitForCommand(ctx, isTerminal, resp.HijackedResponse, idResp, user, workdir)
|
||||
err = cr.waitForCommand(ctx, resp.HijackedResponse, idResp, user, workdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -753,7 +903,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
||||
return cr.tryReadID("-g", func(id int) { cr.GID = id })
|
||||
}
|
||||
|
||||
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
||||
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||
@@ -761,28 +911,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
cmdResponse := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
var outWriter io.Writer
|
||||
outWriter = cr.input.Stdout
|
||||
if outWriter == nil {
|
||||
outWriter = os.Stdout
|
||||
}
|
||||
errWriter := cr.input.Stderr
|
||||
if errWriter == nil {
|
||||
errWriter = os.Stderr
|
||||
}
|
||||
|
||||
var err error
|
||||
if !isTerminal || os.Getenv("NORAW") != "" {
|
||||
_, err = stdcopy.StdCopy(outWriter, errWriter, resp.Reader)
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, resp.Reader)
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line so the
|
||||
// final line of a command's output is not lost (e.g. an error message
|
||||
// printed without a trailing newline before the process exits).
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
cmdResponse <- err
|
||||
cmdResponse <- cr.copyOutput(resp.Reader)
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -819,11 +948,12 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy to %s", destPath)
|
||||
}
|
||||
// Mkdir
|
||||
// Mkdir, with a path relative to the DestinationPath ("/") below. Docker 29.5+
|
||||
// rejects absolute tar entry names with "path escapes from parent".
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
_ = tw.WriteHeader(&tar.Header{
|
||||
Name: destPath,
|
||||
Name: strings.TrimPrefix(destPath, "/"),
|
||||
Mode: 0o777,
|
||||
Typeflag: tar.TypeDir,
|
||||
})
|
||||
@@ -977,33 +1107,11 @@ func (cr *containerReference) attach() common.Executor {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to attach to container: %w", err)
|
||||
}
|
||||
isTerminal := cr.input.AllocatePTY
|
||||
|
||||
var outWriter io.Writer
|
||||
outWriter = cr.input.Stdout
|
||||
if outWriter == nil {
|
||||
outWriter = os.Stdout
|
||||
}
|
||||
errWriter := cr.input.Stderr
|
||||
if errWriter == nil {
|
||||
errWriter = os.Stderr
|
||||
}
|
||||
done := make(chan struct{})
|
||||
cr.attachDone = done
|
||||
go func() {
|
||||
defer close(done)
|
||||
var copyErr error
|
||||
if !isTerminal || os.Getenv("NORAW") != "" {
|
||||
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||
} else {
|
||||
_, copyErr = io.Copy(outWriter, out.Reader)
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once
|
||||
// the stream reaches EOF, so the final line of the container's
|
||||
// output is not lost when it is not newline-terminated.
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
if copyErr != nil {
|
||||
if copyErr := cr.copyOutput(out.Reader); copyErr != nil {
|
||||
common.Logger(ctx).Error(copyErr)
|
||||
}
|
||||
}()
|
||||
@@ -1063,6 +1171,77 @@ func (cr *containerReference) wait() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
|
||||
// workflow-controlled container.options string that could be used to escape the
|
||||
// container when privileged mode is disabled. It must only be called when the
|
||||
// runner has privileged mode turned off; with privileged mode enabled the
|
||||
// administrator has already opted into host access.
|
||||
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
|
||||
warn := func(option string) {
|
||||
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
|
||||
}
|
||||
|
||||
if hostConfig.PidMode != "" {
|
||||
warn("--pid")
|
||||
hostConfig.PidMode = ""
|
||||
}
|
||||
if hostConfig.IpcMode != "" {
|
||||
warn("--ipc")
|
||||
hostConfig.IpcMode = ""
|
||||
}
|
||||
if hostConfig.UTSMode != "" {
|
||||
warn("--uts")
|
||||
hostConfig.UTSMode = ""
|
||||
}
|
||||
if hostConfig.CgroupnsMode != "" {
|
||||
warn("--cgroupns")
|
||||
hostConfig.CgroupnsMode = ""
|
||||
}
|
||||
// UsernsMode is set from the runner-controlled input; never let options
|
||||
// override it (e.g. --userns=host disables user namespace remapping).
|
||||
if hostConfig.UsernsMode != "" {
|
||||
warn("--userns")
|
||||
hostConfig.UsernsMode = ""
|
||||
}
|
||||
if len(hostConfig.CapAdd) > 0 {
|
||||
warn("--cap-add")
|
||||
hostConfig.CapAdd = nil
|
||||
}
|
||||
if len(hostConfig.SecurityOpt) > 0 {
|
||||
warn("--security-opt")
|
||||
hostConfig.SecurityOpt = nil
|
||||
}
|
||||
if len(hostConfig.Devices) > 0 {
|
||||
warn("--device")
|
||||
hostConfig.Devices = nil
|
||||
}
|
||||
if len(hostConfig.DeviceCgroupRules) > 0 {
|
||||
warn("--device-cgroup-rule")
|
||||
hostConfig.DeviceCgroupRules = nil
|
||||
}
|
||||
if len(hostConfig.DeviceRequests) > 0 {
|
||||
warn("--gpus")
|
||||
hostConfig.DeviceRequests = nil
|
||||
}
|
||||
if len(hostConfig.VolumesFrom) > 0 {
|
||||
warn("--volumes-from")
|
||||
hostConfig.VolumesFrom = nil
|
||||
}
|
||||
if hostConfig.Runtime != "" {
|
||||
warn("--runtime")
|
||||
hostConfig.Runtime = ""
|
||||
}
|
||||
if hostConfig.CgroupParent != "" {
|
||||
warn("--cgroup-parent")
|
||||
hostConfig.CgroupParent = ""
|
||||
}
|
||||
if len(hostConfig.Sysctls) > 0 {
|
||||
warn("--sysctl")
|
||||
hostConfig.Sysctls = nil
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||
@@ -1108,6 +1287,34 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
return config, hostConfig
|
||||
}
|
||||
|
||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||
func bindTarget(bind string) string {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Target
|
||||
}
|
||||
|
||||
// overlayVolumes appends src's volumes to dst, dropping the dst ones they mount over. Docker
|
||||
// rejects two mounts on one target, so the volumes declared last have to win.
|
||||
func overlayVolumes(dst, src *container.HostConfig) {
|
||||
claimed := map[string]bool{}
|
||||
for _, bind := range src.Binds {
|
||||
if target := bindTarget(bind); target != "" {
|
||||
claimed[target] = true
|
||||
}
|
||||
}
|
||||
for _, mt := range src.Mounts {
|
||||
claimed[mt.Target] = true
|
||||
}
|
||||
|
||||
dst.Binds = append(slices.DeleteFunc(slices.Clone(dst.Binds),
|
||||
func(bind string) bool { return claimed[bindTarget(bind)] }), src.Binds...)
|
||||
dst.Mounts = append(slices.DeleteFunc(slices.Clone(dst.Mounts),
|
||||
func(mt mount.Mount) bool { return claimed[mt.Target] }), src.Mounts...)
|
||||
}
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []glob.Glob
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -23,6 +24,8 @@ import (
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -76,6 +79,11 @@ type mockDockerClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.ServerVersionResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||
@@ -121,6 +129,26 @@ func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts
|
||||
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
@@ -337,6 +365,44 @@ func TestDockerCopyTarStream(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// Docker 29.5+ rejects absolute names in the mkdir tarball with
|
||||
// "path escapes from parent", since it is extracted relative to "/".
|
||||
func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var mkdirNames []string
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
if opts.DestinationPath != "/" || opts.Content == nil {
|
||||
return false
|
||||
}
|
||||
tr := tar.NewReader(opts.Content)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
mkdirNames = append(mkdirNames, hdr.Name)
|
||||
}
|
||||
return true
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
assert.Equal(t, []string{"var/run/act"}, mkdirNames)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -390,29 +456,39 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
// be logged as one.
|
||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantLogs bool
|
||||
wantWait bool
|
||||
wantFailure bool
|
||||
}{
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")},
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||
{name: "removed cleanly", err: nil},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantLogs: true},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
if tc.wantWait {
|
||||
removed := make(chan container.WaitResponse, 1)
|
||||
removed <- container.WaitResponse{}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
assert.Empty(t, cr.id)
|
||||
|
||||
if tc.wantLogs {
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
assert.Equal(t, "abc", cr.id)
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
client.AssertExpectations(t)
|
||||
@@ -420,6 +496,20 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A container whose id was never learned, because find() could not reach the daemon or
|
||||
// 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.
|
||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||
// daemon with a torn-down container.
|
||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
@@ -487,7 +577,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
check := func(op string, err error) {
|
||||
t.Helper()
|
||||
require.Error(t, err, op)
|
||||
require.ErrorIs(t, err, ErrContainerNotFound, op)
|
||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||
}
|
||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
@@ -496,6 +586,15 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
_, err = cr.Inspect(ctx)
|
||||
check("Inspect", err)
|
||||
|
||||
// a known id the daemon has since dropped
|
||||
client.On("ContainerInspect", ctx, "gone", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
|
||||
removed := &containerReference{id: "gone", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
_, err = removed.Inspect(ctx)
|
||||
check("Inspect after removal", err)
|
||||
}
|
||||
|
||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
||||
@@ -544,7 +643,7 @@ func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
||||
// step that fails on the broken daemon.
|
||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
err := rc.CopyTarStream(ctx, "/var/run/act/actions/", &bytes.Buffer{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -624,6 +723,110 @@ func TestCheckVolumes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
|
||||
dangerous := func() *container.HostConfig {
|
||||
return &container.HostConfig{
|
||||
PidMode: "host",
|
||||
IpcMode: "host",
|
||||
UTSMode: "host",
|
||||
CgroupnsMode: "host",
|
||||
UsernsMode: "host",
|
||||
CapAdd: []string{"ALL"},
|
||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||
VolumesFrom: []string{"other"},
|
||||
Runtime: "runc",
|
||||
Resources: container.Resources{
|
||||
CgroupParent: "/custom",
|
||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
}
|
||||
}
|
||||
|
||||
hostConfig := dangerous()
|
||||
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.Devices)
|
||||
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||
// OS-independent options only: --device parsing requires a linux/windows
|
||||
// server OS, which is not guaranteed for the test host.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
|
||||
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: dangerousOptions,
|
||||
NetworkMode: "bridge",
|
||||
UsernsMode: "private",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: false,
|
||||
UsernsMode: container.UsernsMode("private"),
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hostConfig.Privileged)
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
})
|
||||
|
||||
t.Run("privileged preserves options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||
NetworkMode: "bridge",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: true,
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
@@ -660,3 +863,87 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestContainerInfoFromInspect(t *testing.T) {
|
||||
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
ID: "abc123",
|
||||
State: &container.State{Status: "running", Running: true},
|
||||
})
|
||||
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
assert.Equal(t, "running", info.State)
|
||||
assert.Equal(t, HealthNone, info.Health)
|
||||
assert.Empty(t, info.Ports)
|
||||
})
|
||||
|
||||
t.Run("reports the health status and the last probe output", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
State: &container.State{
|
||||
Status: "running",
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
Log: []*container.HealthcheckResult{
|
||||
{Output: "first\n"},
|
||||
{Output: "connection refused\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, HealthUnhealthy, info.Health)
|
||||
assert.Equal(t, "connection refused", info.HealthOutput)
|
||||
})
|
||||
|
||||
t.Run("reports the published ports", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
State: &container.State{Status: "running"},
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Ports: network.PortMap{
|
||||
network.MustParsePort("5432/tcp"): []network.PortBinding{{HostPort: "49153"}},
|
||||
network.MustParsePort("6379/tcp"): nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]string{"5432": "49153"}, info.Ports)
|
||||
})
|
||||
|
||||
t.Run("tolerates a container without state", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{ID: "abc123"})
|
||||
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
assert.Equal(t, HealthNone, info.Health)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||
assert.Empty(t, hostConf.Mounts)
|
||||
}
|
||||
|
||||
// A dead daemon must fail the job, not panic through logrus and not silently
|
||||
// drop the requested platform.
|
||||
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
||||
cli := &mockDockerClient{}
|
||||
cli.On("ServerVersion", mock.Anything, mock.Anything).
|
||||
Return(mobyclient.ServerVersionResult{}, errors.New("cannot connect to the Docker daemon"))
|
||||
|
||||
supported, err := supportsContainerImagePlatform(t.Context(), cli)
|
||||
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
|
||||
assert.False(t, supported)
|
||||
}
|
||||
@@ -8,12 +8,13 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/moby/moby/api/types/system"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
@@ -72,3 +73,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -154,6 +154,14 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) DumpLogs(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
||||
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
@@ -330,6 +338,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
} else {
|
||||
wd = e.Path
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||
defer common.FlushWriter(e.StdOut)
|
||||
|
||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "Linux",
|
||||
"arch": RunnerArch(ctx),
|
||||
"temp": "/tmp",
|
||||
"tool_cache": "/opt/hostedtoolcache",
|
||||
"tool_cache": DefaultToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
||||
switch search.Kind() {
|
||||
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
||||
return strings.Contains(
|
||||
strings.ToLower(impl.coerceToString(search).String()),
|
||||
strings.ToLower(impl.coerceToString(item).String()),
|
||||
), nil
|
||||
|
||||
case reflect.Slice:
|
||||
for i := 0; i < search.Len(); i++ {
|
||||
arrayItem := search.Index(i).Elem()
|
||||
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isEqual, ok := result.(bool); ok && isEqual {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return strings.HasPrefix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return strings.HasSuffix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
const (
|
||||
passThrough = iota
|
||||
bracketOpen
|
||||
bracketClose
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
||||
input := impl.coerceToString(str).String()
|
||||
var output strings.Builder
|
||||
replacementIndex := ""
|
||||
|
||||
state := passThrough
|
||||
for _, character := range input {
|
||||
switch state {
|
||||
case passThrough: // normal buffer output
|
||||
switch character {
|
||||
case '{':
|
||||
state = bracketOpen
|
||||
|
||||
case '}':
|
||||
state = bracketClose
|
||||
|
||||
default:
|
||||
output.WriteRune(character)
|
||||
}
|
||||
|
||||
case bracketOpen: // found {
|
||||
switch character {
|
||||
case '{':
|
||||
output.WriteString("{")
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
case '}':
|
||||
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
||||
}
|
||||
|
||||
replacementIndex = ""
|
||||
|
||||
if len(replaceValue) <= int(index) {
|
||||
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
||||
}
|
||||
|
||||
output.WriteString(impl.coerceToString(replaceValue[index]).String())
|
||||
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
replacementIndex += string(character)
|
||||
}
|
||||
|
||||
case bracketClose: // found }
|
||||
switch character {
|
||||
case '}':
|
||||
output.WriteString("}")
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
panic("Invalid format parser state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state != passThrough {
|
||||
switch state {
|
||||
case bracketOpen:
|
||||
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
||||
|
||||
case bracketClose:
|
||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||
}
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
separator := impl.coerceToString(sep).String()
|
||||
switch array.Kind() {
|
||||
case reflect.Slice:
|
||||
var items []string
|
||||
for i := 0; i < array.Len(); i++ {
|
||||
items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
|
||||
}
|
||||
|
||||
return strings.Join(items, separator), nil
|
||||
default:
|
||||
return strings.Join([]string{impl.coerceToString(array).String()}, separator), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
||||
if value.Kind() == reflect.Invalid {
|
||||
return "null", nil
|
||||
}
|
||||
|
||||
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
||||
}
|
||||
|
||||
return string(json), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
||||
if value.Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
||||
}
|
||||
|
||||
var data any
|
||||
|
||||
err := json.Unmarshal([]byte(value.String()), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
const cwdPrefix = "." + string(filepath.Separator)
|
||||
const excludeCwdPrefix = "!" + cwdPrefix
|
||||
for _, path := range paths {
|
||||
if path.Kind() == reflect.String {
|
||||
cleanPath := path.String()
|
||||
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
||||
cleanPath = cleanPath[len(cwdPrefix):]
|
||||
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
||||
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
||||
}
|
||||
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
||||
} else {
|
||||
return "", errors.New("Non-string path passed to hashFiles")
|
||||
}
|
||||
}
|
||||
|
||||
matcher := gitignore.NewMatcher(ps)
|
||||
|
||||
var files []string
|
||||
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
||||
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
||||
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("Unable to Close file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
||||
needs := job.Needs()
|
||||
|
||||
for _, need := range needs {
|
||||
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
||||
needs = append(needs, parentNeeds...)
|
||||
}
|
||||
|
||||
return needs
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) always() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].NeedsResult() != "success" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// jobStatus returns the current job status, treating a nil Job context as an
|
||||
// empty status so status-check functions never panic on a nil dereference.
|
||||
func (impl *interperterImpl) jobStatus() string {
|
||||
if impl.env.Job == nil {
|
||||
return ""
|
||||
}
|
||||
return impl.env.Job.Status
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.jobStatus() == "success", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].NeedsResult() == "failure" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.jobStatus() == "failure", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.jobStatus() == "cancelled", nil
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFunctionContains(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"contains('search', 'item') }}", false, "contains-str-str"},
|
||||
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
||||
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
||||
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
||||
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
||||
{`contains(null, '') }}`, true, "contains-null-str"},
|
||||
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
||||
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
||||
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
||||
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
||||
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionStartsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
||||
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
||||
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
||||
{"startsWith(123, 's') }}", false, "startswith-string"},
|
||||
{"startsWith(123, '12') }}", true, "startswith-string"},
|
||||
{"startsWith('123', 12) }}", true, "startswith-string"},
|
||||
{"startsWith(null, '42') }}", false, "startswith-string"},
|
||||
{"startsWith('null', null) }}", true, "startswith-string"},
|
||||
{"startsWith('null', '') }}", true, "startswith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionEndsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
||||
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
||||
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
||||
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
||||
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
||||
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
||||
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
||||
{"endsWith('null', null) }}", true, "endsWith-string"},
|
||||
{"endsWith('null', '') }}", true, "endsWith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionJoin(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
||||
{"join('string', ',')", "string", "join-str"},
|
||||
{"join(1, ',')", "1", "join-number"},
|
||||
{"join(null, ',')", "", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionToJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
||||
{"toJSON(null)", "null", "toJSON-null"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Env: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionFromJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
||||
"foo": "bar",
|
||||
}, "fromJSON"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionHashFiles(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
||||
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
||||
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
||||
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
||||
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
||||
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
||||
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workdir, err := filepath.Abs("testdata")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionFormat(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
error any
|
||||
name string
|
||||
}{
|
||||
{"format('text')", "text", nil, "format-plain-string"},
|
||||
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
||||
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
||||
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
||||
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
||||
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
||||
{"format(true)", "true", nil, "format-with-primitive-args"},
|
||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
||||
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
||||
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
||||
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
||||
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
||||
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
if tt.error != nil {
|
||||
assert.Equal(t, tt.error, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFunctionsNilJob(t *testing.T) {
|
||||
// A nil Job context must not panic: the status-check functions should treat
|
||||
// it as an empty status and return false rather than dereferencing nil.
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
table := []struct {
|
||||
input string
|
||||
context string
|
||||
name string
|
||||
}{
|
||||
{"cancelled()", "job", "cancelled-nil-job"},
|
||||
{"success()", "step", "step-success-nil-job"},
|
||||
{"failure()", "step", "step-failure-nil-job"},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, false, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
-1
@@ -1 +0,0 @@
|
||||
Hello
|
||||
-1
@@ -1 +0,0 @@
|
||||
World!
|
||||
@@ -1 +0,0 @@
|
||||
Knock knock!
|
||||
@@ -1 +0,0 @@
|
||||
Anybody home?
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package ghcontext fills a model.GithubContext from the local git checkout.
|
||||
// The pure data parts of the context live in the shared
|
||||
// gitea.dev/actionslib/pkg/model package, only the helpers that need a
|
||||
// git repository on disk are kept here.
|
||||
package ghcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
var (
|
||||
findGitRef = git.FindGitRef
|
||||
findGitRevision = git.FindGitRevision
|
||||
findGithubRepo = git.FindGithubRepo
|
||||
)
|
||||
|
||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
||||
repoI, ok := event["repository"]
|
||||
if !ok {
|
||||
repoI = make(map[string]any)
|
||||
}
|
||||
|
||||
repo, ok := repoI.(map[string]any)
|
||||
if !ok {
|
||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
||||
return event
|
||||
}
|
||||
|
||||
// if the branch is already there return with no changes
|
||||
if _, ok = repo["default_branch"]; ok {
|
||||
return event
|
||||
}
|
||||
|
||||
repo["default_branch"] = b
|
||||
event["repository"] = repo
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// SetRef resolves the ref of the context from its event payload, falling back
|
||||
// to the ref checked out in repoPath.
|
||||
func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
|
||||
case "release":
|
||||
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
ghc.Ref = model.AsString(ghc.Event["ref"])
|
||||
default:
|
||||
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
if defaultBranch != "" {
|
||||
ghc.Ref = "refs/heads/" + defaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ref, err := findGitRef(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git ref: %v", err)
|
||||
} else {
|
||||
logger.Debugf("using github ref: %s", ref)
|
||||
ghc.Ref = ref
|
||||
}
|
||||
|
||||
// set the branch in the event data
|
||||
if defaultBranch != "" {
|
||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
||||
} else {
|
||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSha resolves the commit of the context from its event payload, falling
|
||||
// back to the revision checked out in repoPath.
|
||||
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
||||
ghc.Sha = model.AsString(ghc.Event["after"])
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Sha == "" {
|
||||
_, sha, err := findGitRevision(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git revision: %v", err)
|
||||
} else {
|
||||
ghc.Sha = sha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetRepositoryAndOwner resolves the repository of the context from the git
|
||||
// remote in repoPath when it is not set yet, and derives its owner.
|
||||
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, remoteName, repoPath string) {
|
||||
if ghc.Repository == "" {
|
||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
||||
return
|
||||
}
|
||||
ghc.Repository = repo
|
||||
}
|
||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
||||
}
|
||||
Loaded 50 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user