mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-26 05:47:45 +00:00
Compare commits
25 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 |
@@ -20,11 +20,10 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created and every artifact already uploaded
|
# has already been created. Fail here instead, before anything
|
||||||
# to S3. Fail here instead, before anything is built or
|
# is built or published, if the R2 secrets are missing.
|
||||||
# published, if the R2 secrets are missing.
|
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -35,6 +34,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
@@ -42,11 +42,6 @@ jobs:
|
|||||||
args: release --nightly
|
args: release --nightly
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
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_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -85,7 +80,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
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
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created and every artifact already uploaded
|
# has already been created. Fail here instead, before anything
|
||||||
# to S3. Fail here instead, before anything is built or
|
# is built or published, if the R2 secrets are missing.
|
||||||
# published, if the R2 secrets are missing.
|
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -27,6 +26,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
@@ -41,11 +41,6 @@ jobs:
|
|||||||
args: release
|
args: release
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
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_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -88,7 +83,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
check-latest: true
|
||||||
- name: prepare anonymous docker config
|
- name: prepare anonymous docker config
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
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`;
|
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||||
|
|||||||
@@ -37,12 +37,8 @@ linters:
|
|||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
deny:
|
deny:
|
||||||
- pkg: io/ioutil
|
|
||||||
desc: use os or io instead
|
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
- pkg: github.com/pkg/errors
|
|
||||||
desc: use builtin errors package instead
|
|
||||||
nolintlint:
|
nolintlint:
|
||||||
allow-unused: false
|
allow-unused: false
|
||||||
require-explanation: true
|
require-explanation: true
|
||||||
|
|||||||
+7
-19
@@ -83,24 +83,12 @@ builds:
|
|||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||||
|
|
||||||
blobs:
|
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
|
||||||
-
|
# isn't usable here since it authenticates from the global AWS_* env
|
||||||
provider: s3
|
# with no per-entry credentials; `publishers:` supports per-entry
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
# `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
|
||||||
region: "{{ .Env.S3_REGION }}"
|
# artifact. Custom publishers inherit almost nothing from the
|
||||||
directory: "gitea-runner/{{.Version}}"
|
# environment, hence the explicit R2_* forwarding below.
|
||||||
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.
|
|
||||||
#
|
#
|
||||||
# This publisher fires 109 times for 73 distinct keys because
|
# This publisher fires 109 times for 73 distinct keys because
|
||||||
# goreleaser's release pipe already registers `release.extra_files`
|
# goreleaser's release pipe already registers `release.extra_files`
|
||||||
@@ -125,7 +113,7 @@ publishers:
|
|||||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: binary
|
- formats: [binary]
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
allow_different_binary_count: true
|
allow_different_binary_count: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
- Never assume, verify before claiming
|
||||||
- Use `make help` to find available development targets
|
- Use `make help` to find available development targets
|
||||||
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
- PR descriptions: minimal, only what and why, no task lists or file listings
|
||||||
- Run `make tidy` after any `go.mod` changes
|
- Reference issues and PRs by full URL, not by number
|
||||||
- Run single go unit tests with `go test -run '^TestName$' ./modulepath/`
|
- Use Conventional Commits for commit messages and PR titles, plus the `enhance` type for user-facing enhancements
|
||||||
- Add the current year into the copyright header of new `.go` files
|
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
|
||||||
- Ensure no trailing whitespace in edited files
|
- 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
|
- 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
|
- 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
|
||||||
- Include authorship attribution in issue and pull request comments
|
- Add the current year into the copyright header of new `.go` files
|
||||||
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
- 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
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.2-dind AS dind
|
FROM docker:29.7.1-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### 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
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
|
|||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.26.x
|
XGO_VERSION := go-1.26.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.16 # renovate: datasource=go
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
@@ -19,7 +19,7 @@ DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
|||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -141,7 +141,12 @@ security-check:
|
|||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy: ## run go mod 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
|
$(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
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: tidy
|
||||||
|
|||||||
@@ -129,27 +129,64 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a
|
|||||||
|
|
||||||
### Configuration
|
### 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
|
```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
|
```bash
|
||||||
./gitea-runner -c config.yaml register
|
./gitea-runner config set runner.capacity 4
|
||||||
./gitea-runner -c config.yaml daemon
|
./gitea-runner config set runner.timeout 90m # written as 1h30m0s
|
||||||
./gitea-runner -c config.yaml cache-server
|
./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
|
### Labels
|
||||||
|
|
||||||
@@ -209,6 +246,26 @@ 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.
|
> **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
|
#### Proxy
|
||||||
|
|
||||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
@@ -242,6 +299,12 @@ A password in a proxy URL is hidden in job logs. Any step can still read it, bec
|
|||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server 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**
|
**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:
|
`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:
|
||||||
@@ -282,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
|
# 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.
|
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.
|
**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.
|
||||||
|
|||||||
+312
-106
@@ -5,6 +5,7 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -27,6 +29,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/disk"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -52,7 +55,16 @@ type credKey struct{}
|
|||||||
// poison another repo's cache, even from inside a container that reaches the
|
// poison another repo's cache, even from inside a container that reaches the
|
||||||
// cache server over the docker bridge network.
|
// cache server over the docker bridge network.
|
||||||
type JobCredential struct {
|
type JobCredential struct {
|
||||||
Repo string
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
|
// Results is the instance whose artifact service this server forwards for the job, and
|
||||||
|
// InsecureTLS how the runner reaches it; see results.go. The tags are the wire format a
|
||||||
|
// remote runner registers with.
|
||||||
|
Results string `json:"results"`
|
||||||
|
InsecureTLS bool `json:"insecure_tls"`
|
||||||
|
|
||||||
|
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
// credEntry holds a registered job's credential along with an active
|
||||||
@@ -94,19 +106,38 @@ type Handler struct {
|
|||||||
|
|
||||||
credMu sync.RWMutex
|
credMu sync.RWMutex
|
||||||
creds map[string]*credEntry
|
creds map[string]*credEntry
|
||||||
|
|
||||||
|
policy Policy
|
||||||
|
|
||||||
|
// freeDisk is a field so tests can drive evictForFreeSpace without a full volume.
|
||||||
|
freeDisk func(string) (uint64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures a cache server started by StartHandler; the zero value is usable.
|
||||||
|
type Options struct {
|
||||||
|
Dir string
|
||||||
|
OutboundIP string
|
||||||
|
Port uint16
|
||||||
|
|
||||||
|
// InternalSecret, when non-empty, enables a control-plane API at
|
||||||
|
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||||
|
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
||||||
|
// embedded in-process handler leaves it empty and registers tokens via the
|
||||||
|
// in-process RegisterJob method directly.
|
||||||
|
InternalSecret string
|
||||||
|
|
||||||
|
Policy Policy
|
||||||
|
Logger logrus.FieldLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
||||||
//
|
func StartHandler(opts Options) (*Handler, error) {
|
||||||
// internalSecret, when non-empty, enables a control-plane API at
|
dir, logger := opts.Dir, opts.Logger
|
||||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
|
||||||
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
|
||||||
// embedded in-process handler leaves it empty and registers tokens via the
|
|
||||||
// in-process RegisterJob method directly.
|
|
||||||
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
creds: make(map[string]*credEntry),
|
creds: make(map[string]*credEntry),
|
||||||
internalSecret: internalSecret,
|
internalSecret: opts.InternalSecret,
|
||||||
|
policy: opts.Policy.withDefaults(),
|
||||||
|
freeDisk: disk.FreeBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
@@ -136,8 +167,8 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
}
|
}
|
||||||
h.storage = storage
|
h.storage = storage
|
||||||
|
|
||||||
if outboundIP != "" {
|
if opts.OutboundIP != "" {
|
||||||
h.outboundIP = outboundIP
|
h.outboundIP = opts.OutboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
return nil, errors.New("unable to determine outbound IP address")
|
||||||
} else {
|
} else {
|
||||||
@@ -165,6 +196,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||||
h.registerV2Routes(router)
|
h.registerV2Routes(router)
|
||||||
|
router.NotFound = http.HandlerFunc(h.forwardOrNotFound)
|
||||||
|
|
||||||
h.router = router
|
h.router = router
|
||||||
|
|
||||||
@@ -175,7 +207,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
// can break Docker Desktop variants where the host's outbound IP is not
|
// can break Docker Desktop variants where the host's outbound IP is not
|
||||||
// routable from inside the container network. Authentication is enforced
|
// routable from inside the container network. Authentication is enforced
|
||||||
// by the bearer middleware and per-repo scoping, not by reachability.
|
// by the bearer middleware and per-repo scoping, not by reachability.
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -205,16 +237,24 @@ func (h *Handler) ExternalURL() string {
|
|||||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
|
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return h.ExternalURL()
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||||
// the given repository and returns a function that removes it. The runner
|
// the given repository and returns a function that removes it. The runner
|
||||||
// calls this at job start and defers the returned func so that the credential
|
// calls this at job start and defers the returned func so that the credential
|
||||||
// is only accepted while the job is running.
|
// is only accepted while the job is running.
|
||||||
//
|
//
|
||||||
// Registrations are reference-counted: if a token is already registered, the
|
// Registrations are reference-counted: if a token is already registered, the
|
||||||
// existing repo is kept and the refcount is incremented. The entry is
|
// credential it was registered with is kept and the refcount is incremented.
|
||||||
// removed only when every revoker returned by RegisterJob has been called.
|
// The entry is removed only when every revoker returned by RegisterJob has
|
||||||
|
// been called.
|
||||||
// This keeps a stray re-registration from silently revoking a live job.
|
// This keeps a stray re-registration from silently revoking a live job.
|
||||||
func (h *Handler) RegisterJob(token, repo string) func() {
|
func (h *Handler) RegisterJob(token string, cred JobCredential) func() {
|
||||||
if h == nil || token == "" {
|
if h == nil || token == "" {
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
@@ -223,7 +263,7 @@ func (h *Handler) RegisterJob(token, repo string) func() {
|
|||||||
existing.refs++
|
existing.refs++
|
||||||
} else {
|
} else {
|
||||||
h.creds[token] = &credEntry{
|
h.creds[token] = &credEntry{
|
||||||
cred: JobCredential{Repo: repo},
|
cred: cred,
|
||||||
refs: 1,
|
refs: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +391,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
}
|
}
|
||||||
h.responseJSON(w, r, 200, map[string]any{
|
h.responseJSON(w, r, 200, map[string]any{
|
||||||
"result": "hit",
|
"result": "hit",
|
||||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
"archiveLocation": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"cacheKey": cache.Key,
|
"cacheKey": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -372,6 +412,9 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
_ = db.Delete(cache.ID, cache)
|
_ = db.Delete(cache.ID, cache)
|
||||||
return nil, nil //nolint:nilnil // absence is not an error here
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
}
|
}
|
||||||
|
// Handing out a download URL counts as access, or eviction could drop the entry between
|
||||||
|
// this call and the GET that follows it.
|
||||||
|
h.touch(db, cache)
|
||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,13 +552,22 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||||
cache.Size = written
|
cache.Size = written
|
||||||
cache.Complete = true
|
cache.Complete = true
|
||||||
|
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
|
||||||
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
return db.Update(cache.ID, cache)
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A commit is the only thing that grows the store, so the only thing that can push the
|
||||||
|
// volume under the floor.
|
||||||
|
h.evictRepo(db, cache.Repo)
|
||||||
|
h.evictTotal(db)
|
||||||
|
h.evictForFreeSpace(db)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/artifacts/:id
|
// GET /_apis/artifactcache/artifacts/:id
|
||||||
@@ -619,7 +671,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
|
|
||||||
type internalRegisterBody struct {
|
type internalRegisterBody struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Repo string `json:"repo"`
|
JobCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
type internalRevokeBody struct {
|
type internalRevokeBody struct {
|
||||||
@@ -627,6 +679,15 @@ type internalRevokeBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/register
|
// POST /_internal/register
|
||||||
|
// ResultsURL is what a job registered with cred should be given as ACTIONS_RESULTS_URL, or "" when
|
||||||
|
// the credential names no instance to forward the artifact half to.
|
||||||
|
func (h *Handler) ResultsURL(cred JobCredential) string {
|
||||||
|
if h == nil || cred.Results == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.baseURL(cred)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
var body internalRegisterBody
|
var body internalRegisterBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -637,8 +698,9 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.RegisterJob(body.Token, body.Repo)
|
h.RegisterJob(body.Token, body.JobCredential)
|
||||||
h.responseJSON(w, r, http.StatusOK)
|
// A server too old to forward answers without this, which is how the caller knows.
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/revoke
|
// POST /_internal/revoke
|
||||||
@@ -682,16 +744,16 @@ func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||||
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
func (h *Handler) signedURL(cred JobCredential, path, purpose string, cacheID uint64, exp time.Time) string {
|
||||||
expUnix := exp.Unix()
|
expUnix := exp.Unix()
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||||
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||||
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
return fmt.Sprintf("%s%s/%d?%s", h.baseURL(cred), path, cacheID, q.Encode())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time.Time) string {
|
||||||
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// if not found, return (nil, nil) instead of an error.
|
||||||
@@ -793,12 +855,43 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
keepUsed = 30 * 24 * time.Hour
|
miB = 1024 * 1024
|
||||||
keepUnused = 7 * 24 * time.Hour
|
|
||||||
keepTemp = 5 * time.Minute
|
defaultSweepInterval = time.Hour
|
||||||
keepOld = 5 * time.Minute
|
|
||||||
|
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
||||||
|
// for it, and no sweep cuts off a download in progress.
|
||||||
|
inUseGrace = artifactURLTTL
|
||||||
|
|
||||||
|
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
||||||
|
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
||||||
|
// a stale reservation.
|
||||||
|
uploadStallTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
defaultMinFreeDisk = 1024 * miB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Policy bounds what the cache server keeps: a retention window counted from last access,
|
||||||
|
// and size limits that evict least recently accessed first. A zero limit is no limit.
|
||||||
|
type Policy struct {
|
||||||
|
Retention time.Duration // Retention removes entries nothing has read or written within this window. Zero keeps them regardless of age.
|
||||||
|
RepoSizeLimit int64 // RepoSizeLimit caps one repository's completed entries in bytes, evicting least recently accessed first.
|
||||||
|
SizeLimit int64 // SizeLimit caps every repository's completed entries together, in bytes.
|
||||||
|
SweepInterval time.Duration // SweepInterval is the minimum time between two eviction sweeps.
|
||||||
|
MinFreeDisk int64 // MinFreeDisk is volume headroom the cache will not eat into. Tracks the runner's health-check floor rather than taking a key of its own.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Policy) withDefaults() Policy {
|
||||||
|
// The limits default in config.LoadDefault, so a written 0 means off.
|
||||||
|
if p.MinFreeDisk <= 0 {
|
||||||
|
p.MinFreeDisk = defaultMinFreeDisk
|
||||||
|
}
|
||||||
|
if p.SweepInterval <= 0 {
|
||||||
|
p.SweepInterval = defaultSweepInterval
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) gcCache() {
|
func (h *Handler) gcCache() {
|
||||||
if h.gcing.Load() {
|
if h.gcing.Load() {
|
||||||
return
|
return
|
||||||
@@ -808,7 +901,7 @@ func (h *Handler) gcCache() {
|
|||||||
}
|
}
|
||||||
defer h.gcing.Store(false)
|
defer h.gcing.Store(false)
|
||||||
|
|
||||||
if time.Since(h.gcAt) < time.Hour {
|
if time.Since(h.gcAt) < h.policy.SweepInterval {
|
||||||
h.logger.Debugf("skip gc: %v", h.gcAt.String())
|
h.logger.Debugf("skip gc: %v", h.gcAt.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -821,95 +914,208 @@ func (h *Handler) gcCache() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// Remove the caches which are not completed for a while, they are most likely to be broken.
|
h.evictIncomplete(db)
|
||||||
var caches []*Cache
|
h.evictExpired(db)
|
||||||
if err := db.Find(&caches, bolthold.
|
h.evictSuperseded(db)
|
||||||
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
|
h.evictOversized(db)
|
||||||
And("Complete").Eq(false),
|
h.evictForFreeSpace(db)
|
||||||
); err != nil {
|
}
|
||||||
h.logger.Warnf("find caches: %v", err)
|
|
||||||
} else {
|
// evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never
|
||||||
for _, cache := range caches {
|
// accounted for.
|
||||||
h.storage.Remove(cache.ID)
|
func (h *Handler) evictForFreeSpace(db *bolthold.Store) {
|
||||||
if err := db.Delete(cache.ID, cache); err != nil {
|
free, err := h.freeDisk(h.dir)
|
||||||
h.logger.Warnf("delete cache: %v", err)
|
if err != nil {
|
||||||
continue
|
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
|
||||||
}
|
return
|
||||||
h.logger.Infof("deleted cache: %+v", cache)
|
}
|
||||||
}
|
if free >= uint64(h.policy.MinFreeDisk) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the old caches which have not been used recently.
|
caches := h.completedByUse(db)
|
||||||
caches = caches[:0]
|
total, shortfall := totalSize(caches), h.policy.MinFreeDisk-int64(free)
|
||||||
if err := db.Find(&caches, bolthold.
|
if total <= shortfall {
|
||||||
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
|
// Say so, or shedding everything and still being short reads as the backstop working.
|
||||||
); err != nil {
|
h.logger.Warnf("cache volume is %d MiB short of the free space floor with only %d MiB of cache on it; something else is filling it", shortfall/miB, total/miB)
|
||||||
h.logger.Warnf("find caches: %v", err)
|
|
||||||
} else {
|
|
||||||
for _, cache := range caches {
|
|
||||||
h.storage.Remove(cache.ID)
|
|
||||||
if err := db.Delete(cache.ID, cache); err != nil {
|
|
||||||
h.logger.Warnf("delete cache: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.logger.Infof("deleted cache: %+v", cache)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
h.evictTo(db, caches, total-shortfall, "the cache volume")
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the old caches which are too old.
|
// evictIncomplete removes uploads that stopped part way, which are most likely broken.
|
||||||
caches = caches[:0]
|
func (h *Handler) evictIncomplete(db *bolthold.Store) {
|
||||||
if err := db.Find(&caches, bolthold.
|
h.sweep(db, bolthold.
|
||||||
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
|
Where("UsedAt").Lt(time.Now().Add(-uploadStallTimeout).Unix()).
|
||||||
); err != nil {
|
And("Complete").Eq(false).
|
||||||
h.logger.Warnf("find caches: %v", err)
|
Index("UsedAt"))
|
||||||
} else {
|
}
|
||||||
for _, cache := range caches {
|
|
||||||
h.storage.Remove(cache.ID)
|
func (h *Handler) evictExpired(db *bolthold.Store) {
|
||||||
if err := db.Delete(cache.ID, cache); err != nil {
|
if h.policy.Retention <= 0 {
|
||||||
h.logger.Warnf("delete cache: %v", err)
|
return
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.logger.Infof("deleted cache: %+v", cache)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
||||||
|
window := max(h.policy.Retention, inUseGrace)
|
||||||
|
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the old caches with the same key and version within the same
|
// evictSuperseded removes entries a newer one with the same key and version replaced. The
|
||||||
// repository, keep the latest one. Aggregation must include Repo so two
|
// aggregation includes Repo so two repos sharing a (key, version) do not evict each other.
|
||||||
// repos that happen to share a (key, version) do not evict each other —
|
func (h *Handler) evictSuperseded(db *bolthold.Store) {
|
||||||
// otherwise per-repo scoping holds for reads but one repo can age
|
results, err := db.FindAggregate(&Cache{}, bolthold.Where("Complete").Eq(true).Index("Complete"), "Repo", "Key", "Version")
|
||||||
// another out after keepOld.
|
if err != nil {
|
||||||
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
|
||||||
if results, err := db.FindAggregate(
|
|
||||||
&Cache{},
|
|
||||||
bolthold.Where("Complete").Eq(true),
|
|
||||||
"Repo", "Key", "Version",
|
|
||||||
); err != nil {
|
|
||||||
h.logger.Warnf("find aggregate caches: %v", err)
|
h.logger.Warnf("find aggregate caches: %v", err)
|
||||||
} else {
|
return
|
||||||
for _, result := range results {
|
}
|
||||||
if result.Count() <= 1 {
|
var caches []*Cache
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Count() <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Sort("CreatedAt")
|
||||||
|
caches = caches[:0]
|
||||||
|
result.Reduction(&caches)
|
||||||
|
for _, cache := range caches[:len(caches)-1] {
|
||||||
|
if inUse(cache) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Sort("CreatedAt")
|
h.deleteCache(db, cache)
|
||||||
caches = caches[:0]
|
|
||||||
result.Reduction(&caches)
|
|
||||||
for _, cache := range caches[:len(caches)-1] {
|
|
||||||
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
|
|
||||||
// Keep it since it has been used recently, even if it's old.
|
|
||||||
// Or it could break downloading in process.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.storage.Remove(cache.ID)
|
|
||||||
if err := db.Delete(cache.ID, cache); err != nil {
|
|
||||||
h.logger.Warnf("delete cache: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.logger.Infof("deleted cache: %+v", cache)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictOversized applies the per-repository limit, then the whole-store one. Only completed
|
||||||
|
// entries count, since only those carry a size measured at commit rather than claimed.
|
||||||
|
func (h *Handler) evictOversized(db *bolthold.Store) {
|
||||||
|
if h.policy.RepoSizeLimit > 0 {
|
||||||
|
byRepo := make(map[string][]*Cache)
|
||||||
|
for _, cache := range h.completedByUse(db) {
|
||||||
|
byRepo[cache.Repo] = append(byRepo[cache.Repo], cache)
|
||||||
|
}
|
||||||
|
for repo, caches := range byRepo {
|
||||||
|
h.evictTo(db, caches, h.policy.RepoSizeLimit, "repository "+repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.evictTotal(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictTotal caps the store as a whole. It re-queries because the per-repo pass may have
|
||||||
|
// deleted rows an earlier result still holds.
|
||||||
|
func (h *Handler) evictTotal(db *bolthold.Store) {
|
||||||
|
if h.policy.SizeLimit <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.evictTo(db, h.completedByUse(db), h.policy.SizeLimit, "the cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictRepo reclaims space when a commit pushes a repo over, rather than at the next sweep.
|
||||||
|
func (h *Handler) evictRepo(db *bolthold.Store, repo string) {
|
||||||
|
if h.policy.RepoSizeLimit <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.evictTo(db, h.cachesByUse(db, bolthold.Where("Repo").Eq(repo).And("Complete").Eq(true).Index("Repo")), h.policy.RepoSizeLimit, "repository "+repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictTo deletes until caches fit limit. caches must be ordered by UsedAt ascending.
|
||||||
|
func (h *Handler) evictTo(db *bolthold.Store, caches []*Cache, limit int64, scope string) {
|
||||||
|
// An entry bigger than the limit never fits, so it goes on its own account instead of
|
||||||
|
// dragging every neighbour out first and then following them next sweep.
|
||||||
|
fits := caches[:0]
|
||||||
|
for _, cache := range caches {
|
||||||
|
if cache.Size <= limit {
|
||||||
|
fits = append(fits, cache)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inUse(cache) {
|
||||||
|
h.logger.Warnf("cache %q is %d MiB on its own, over the limit for %s; dropping it", cache.Key, cache.Size/miB, scope)
|
||||||
|
h.deleteCache(db, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caches = fits
|
||||||
|
|
||||||
|
total := totalSize(caches)
|
||||||
|
var freed int64
|
||||||
|
for _, cache := range caches {
|
||||||
|
if total <= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inUse(cache) || !h.deleteCache(db, cache) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total -= cache.Size
|
||||||
|
freed += cache.Size
|
||||||
|
}
|
||||||
|
if freed > 0 {
|
||||||
|
h.logger.Warnf("evicted %d MiB from %s, least recently used first", freed/miB, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inUse reports whether an entry was read or written recently enough that removing it
|
||||||
|
// could break a download in progress.
|
||||||
|
func inUse(cache *Cache) bool {
|
||||||
|
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
||||||
|
}
|
||||||
|
|
||||||
|
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
||||||
|
// go through touchCache, which opens its own store and would block on the exclusive lock
|
||||||
|
// for as long as the caller holds one.
|
||||||
|
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
||||||
|
cache.UsedAt = time.Now().Unix()
|
||||||
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("touch cache: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sweep(db *bolthold.Store, query *bolthold.Query) {
|
||||||
|
for _, cache := range h.caches(db, query) {
|
||||||
|
h.deleteCache(db, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) caches(db *bolthold.Store, query *bolthold.Query) []*Cache {
|
||||||
|
var caches []*Cache
|
||||||
|
if err := db.Find(&caches, query); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
}
|
||||||
|
return caches
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachesByUse returns matches least recently accessed first, sorting here rather than with
|
||||||
|
// bolthold's SortBy, which reflects over every field it compares.
|
||||||
|
func (h *Handler) cachesByUse(db *bolthold.Store, query *bolthold.Query) []*Cache {
|
||||||
|
caches := h.caches(db, query)
|
||||||
|
slices.SortFunc(caches, func(a, b *Cache) int { return cmp.Compare(a.UsedAt, b.UsedAt) })
|
||||||
|
return caches
|
||||||
|
}
|
||||||
|
|
||||||
|
// completedByUse returns every entry the size limits count, least recently accessed first.
|
||||||
|
func (h *Handler) completedByUse(db *bolthold.Store) []*Cache {
|
||||||
|
return h.cachesByUse(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalSize(caches []*Cache) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, cache := range caches {
|
||||||
|
total += cache.Size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
||||||
|
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
||||||
|
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
||||||
|
if err := h.storage.Remove(cache.ID); err != nil {
|
||||||
|
h.logger.Warnf("remove cache blob: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
var data []byte
|
var data []byte
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -41,18 +42,21 @@ func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
||||||
|
|
||||||
|
// testRetention mirrors config.DefaultCacheRetention; Policy has no defaults of its own.
|
||||||
|
const testRetention = 7 * 24 * time.Hour
|
||||||
|
|
||||||
// signArtifactURL builds a signed download URL the same way the server does;
|
// signArtifactURL builds a signed download URL the same way the server does;
|
||||||
// tests use it to reach the get handler directly without going through a
|
// tests use it to reach the get handler directly without going through a
|
||||||
// find/cache-hit round trip.
|
// find/cache-hit round trip.
|
||||||
func signArtifactURL(h *Handler, id int64) string {
|
func signArtifactURL(h *Handler, id int64) string {
|
||||||
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
|
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
||||||
|
|
||||||
@@ -656,7 +660,7 @@ func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration
|
|||||||
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) {
|
||||||
var id uint64
|
var id uint64
|
||||||
{
|
{
|
||||||
body, err := json.Marshal(&Request{
|
body, err := json.Marshal(&Request{
|
||||||
@@ -722,7 +726,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
|
|
||||||
func TestHandler_gcCache(t *testing.T) {
|
func TestHandler_gcCache(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir, Policy: Policy{Retention: testRetention}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -752,8 +756,8 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_2",
|
Key: "test_key_2",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: false,
|
Complete: false,
|
||||||
UsedAt: now.Add(-(keepTemp + time.Second)).Unix(),
|
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(keepTemp + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(inUseGrace + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
},
|
},
|
||||||
@@ -763,21 +767,21 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(keepUnused + time.Second)).Unix(),
|
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(keepUnused + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// should be removed, since it's used but too old.
|
// should be kept, since age alone does not retire an entry that is still used.
|
||||||
Cache: &Cache{
|
Cache: &Cache{
|
||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Unix(),
|
UsedAt: now.Unix(),
|
||||||
CreatedAt: now.Add(-(keepUsed + time.Second)).Unix(),
|
CreatedAt: now.Add(-365 * 24 * time.Hour).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// should be kept, since it has a newer edition but be used recently.
|
// should be kept, since it has a newer edition but be used recently.
|
||||||
@@ -785,7 +789,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_1",
|
Key: "test_key_1",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(keepOld - time.Minute)).Unix(),
|
UsedAt: now.Add(-(inUseGrace - time.Minute)).Unix(),
|
||||||
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: true,
|
Kept: true,
|
||||||
@@ -796,7 +800,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_1",
|
Key: "test_key_1",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(keepOld + time.Second)).Unix(),
|
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
@@ -829,11 +833,265 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandler_evictPolicy covers the non-default policies; TestHandler_gcCache covers the
|
||||||
|
// defaults across every pass.
|
||||||
|
func TestHandler_evictPolicy(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
stale := func(d time.Duration) int64 { return now.Add(-d).Unix() }
|
||||||
|
mib := func(n int64) int64 { return n * miB }
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
policy Policy
|
||||||
|
entries []*Cache
|
||||||
|
kept []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a zero retention keeps an entry nothing has touched",
|
||||||
|
policy: Policy{Retention: 0},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Key: "idle", UsedAt: stale(testRetention + time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"idle"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "evicts least recently accessed until the repository fits",
|
||||||
|
policy: Policy{RepoSizeLimit: mib(10)},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "oldest", Size: mib(4), UsedAt: stale(3 * time.Hour)},
|
||||||
|
{Repo: "o/a", Key: "middle", Size: mib(4), UsedAt: stale(2 * time.Hour)},
|
||||||
|
{Repo: "o/a", Key: "newest", Size: mib(4), UsedAt: stale(time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"middle", "newest"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spares entries that may still be downloading",
|
||||||
|
policy: Policy{RepoSizeLimit: mib(10)},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "fresh_1", Size: mib(6), UsedAt: stale(time.Minute)},
|
||||||
|
{Repo: "o/a", Key: "fresh_2", Size: mib(6), UsedAt: stale(time.Minute)},
|
||||||
|
},
|
||||||
|
kept: []string{"fresh_1", "fresh_2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one repository over its limit leaves another alone",
|
||||||
|
policy: Policy{RepoSizeLimit: mib(10)},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "a_old", Size: mib(6), UsedAt: stale(3 * time.Hour)},
|
||||||
|
{Repo: "o/a", Key: "a_new", Size: mib(6), UsedAt: stale(time.Hour)},
|
||||||
|
{Repo: "o/b", Key: "b_old", Size: mib(6), UsedAt: stale(4 * time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"a_new", "b_old"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the total limit evicts across repositories once each fits its own",
|
||||||
|
policy: Policy{RepoSizeLimit: mib(10), SizeLimit: mib(12)},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "a_old", Size: mib(8), UsedAt: stale(3 * time.Hour)},
|
||||||
|
{Repo: "o/b", Key: "b_new", Size: mib(8), UsedAt: stale(time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"b_new"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Retention below inUseGrace would otherwise drop an entry whose signed URL a job
|
||||||
|
// is still holding.
|
||||||
|
name: "a retention shorter than the grace still spares a just-served entry",
|
||||||
|
policy: Policy{Retention: time.Minute},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Key: "just_served", UsedAt: stale(2 * time.Minute)},
|
||||||
|
{Key: "idle", UsedAt: stale(time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"just_served"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an entry over the limit goes without emptying the repository",
|
||||||
|
policy: Policy{RepoSizeLimit: mib(10)},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "keeps", Size: mib(4), UsedAt: stale(3 * time.Hour)},
|
||||||
|
{Repo: "o/a", Key: "huge", Size: mib(20), UsedAt: stale(2 * time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"keeps"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a zero limit keeps everything",
|
||||||
|
policy: Policy{RepoSizeLimit: 0},
|
||||||
|
entries: []*Cache{
|
||||||
|
{Repo: "o/a", Key: "huge_1", Size: mib(100), UsedAt: stale(3 * time.Hour)},
|
||||||
|
{Repo: "o/a", Key: "huge_2", Size: mib(100), UsedAt: stale(2 * time.Hour)},
|
||||||
|
},
|
||||||
|
kept: []string{"huge_1", "huge_2"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
for _, e := range tc.entries {
|
||||||
|
e.Complete = true // only completed entries carry a measured size, so only they count
|
||||||
|
}
|
||||||
|
handler := newTestHandler(t, tc.policy, tc.entries...)
|
||||||
|
handler.gcAt = time.Time{} // ensure gcCache will not skip
|
||||||
|
handler.gcCache()
|
||||||
|
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, tc.entries))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_evictForFreeSpace proves the volume backstop sheds only what it must, and only
|
||||||
|
// when the disk is actually short.
|
||||||
|
func TestHandler_evictForFreeSpace(t *testing.T) {
|
||||||
|
free := func(n int64) func(string) (uint64, error) {
|
||||||
|
return func(string) (uint64, error) { return uint64(n), nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
freeDisk func(string) (uint64, error)
|
||||||
|
kept []string
|
||||||
|
}{
|
||||||
|
{"ample free space evicts nothing", free(defaultMinFreeDisk), []string{"oldest", "middle", "newest"}},
|
||||||
|
{"a small shortfall sheds one entry", free(defaultMinFreeDisk - 4*miB), []string{"middle", "newest"}},
|
||||||
|
{"a shortfall the cache cannot cover sheds all of it", free(0), nil},
|
||||||
|
{
|
||||||
|
"an unreadable volume is treated as unavailable, not as full",
|
||||||
|
func(string) (uint64, error) { return 0, errors.New("unsupported") },
|
||||||
|
[]string{"oldest", "middle", "newest"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
entries := []*Cache{
|
||||||
|
{Key: "oldest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-3 * time.Hour).Unix()},
|
||||||
|
{Key: "middle", Complete: true, Size: 4 * miB, UsedAt: now.Add(-2 * time.Hour).Unix()},
|
||||||
|
{Key: "newest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-time.Hour).Unix()},
|
||||||
|
}
|
||||||
|
handler := newTestHandler(t, Policy{}, entries...)
|
||||||
|
handler.freeDisk = tc.freeDisk
|
||||||
|
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
handler.evictForFreeSpace(db)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, entries))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_SweepKeepsEntryWhenBlobSurvives proves a failed unlink leaves the row in place,
|
||||||
|
// so the next sweep retries rather than orphaning bytes no row points at and no limit counts.
|
||||||
|
func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
|
||||||
|
cache := &Cache{Key: "stuck", Complete: true, UsedAt: time.Now().Add(-(testRetention + time.Hour)).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
||||||
|
|
||||||
|
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
||||||
|
blob := handler.storage.filename(cache.ID)
|
||||||
|
require.NoError(t, os.MkdirAll(blob, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
||||||
|
|
||||||
|
handler.gcAt = time.Time{}
|
||||||
|
handler.gcCache()
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"stuck"}, keptKeys(t, handler, []*Cache{cache}), "the entry must outlive a blob that could not be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_FindProtectsFromEviction covers the window between a find handing out a signed
|
||||||
|
// download URL and the GET that redeems it: the entry promised to a job must not be the next
|
||||||
|
// eviction victim just because its last access predates the find.
|
||||||
|
func TestHandler_FindProtectsFromEviction(t *testing.T) {
|
||||||
|
// 12 MiB against a 10 MiB limit, so exactly one entry has to go.
|
||||||
|
wanted := &Cache{Repo: testRepo, Key: "wanted", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-3 * time.Hour).Unix()}
|
||||||
|
other := &Cache{Repo: testRepo, Key: "other", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-2 * time.Hour).Unix()}
|
||||||
|
newest := &Cache{Repo: testRepo, Key: "newest", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{RepoSizeLimit: 10 * miB}, wanted, other, newest)
|
||||||
|
writeBlob(t, handler, wanted.ID) // find only reports a hit when the blob is on disk
|
||||||
|
|
||||||
|
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=wanted&version=v", handler.ExternalURL(), apiPath))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
// Evict directly: the request above kicked off an async gcCache, and writing gcAt here
|
||||||
|
// to drive gcCache would race its read.
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
handler.evictOversized(db)
|
||||||
|
|
||||||
|
require.NoError(t, db.Get(wanted.ID, &Cache{}), "the entry just promised to a job must survive")
|
||||||
|
assert.ErrorIs(t, db.Get(other.ID, &Cache{}), bolthold.ErrNotFound, "the next least recently used goes instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_evictOnCommit proves a repository that goes over its limit gets space back at
|
||||||
|
// once, rather than waiting out the collection interval.
|
||||||
|
func TestHandler_evictOnCommit(t *testing.T) {
|
||||||
|
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{RepoSizeLimit: 4 * miB}, full)
|
||||||
|
|
||||||
|
// StartHandler already stamped gcAt, so the periodic sweep stays rate-limited out and
|
||||||
|
// only the commit path can evict.
|
||||||
|
uploadCacheNormally(t, handler.ExternalURL()+apiPath, "new", "v", []byte("some content"))
|
||||||
|
|
||||||
|
assert.Empty(t, keptKeys(t, handler, []*Cache{full}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_gcCacheInterval(t *testing.T) {
|
||||||
|
cache := &Cache{Key: "temp", UsedAt: time.Now().Add(-time.Hour).Unix()}
|
||||||
|
// Half the default, so a sweep 45m ago is still inside the default but past this one.
|
||||||
|
handler := newTestHandler(t, Policy{SweepInterval: 30 * time.Minute}, cache)
|
||||||
|
|
||||||
|
handler.gcAt = time.Now().Add(-45 * time.Minute) // past the configured interval, still inside the default
|
||||||
|
handler.gcCache()
|
||||||
|
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
||||||
|
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
handler, err := StartHandler(Options{
|
||||||
|
Dir: filepath.Join(t.TempDir(), "artifactcache"),
|
||||||
|
OutboundIP: "127.0.0.1",
|
||||||
|
Policy: policy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
||||||
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, e := range entries {
|
||||||
|
require.NoError(t, insertCache(db, e))
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// keptKeys reports which of entries are still in the store.
|
||||||
|
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
||||||
|
t.Helper()
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, db.Close()) }()
|
||||||
|
|
||||||
|
var kept []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := db.Get(e.ID, &Cache{}); err == nil {
|
||||||
|
kept = append(kept, e.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBlob gives an entry the on-disk bytes that find and get require.
|
||||||
|
func writeBlob(t *testing.T, handler *Handler, id uint64) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a")))
|
||||||
|
_, err := handler.storage.Commit(id, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
||||||
// unauthenticated access to management endpoints is now refused with 401.
|
// unauthenticated access to management endpoints is now refused with 401.
|
||||||
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -866,7 +1124,7 @@ func TestHandler_RejectsMissingBearer(t *testing.T) {
|
|||||||
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
||||||
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -886,11 +1144,11 @@ func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
|||||||
// working the moment the job ends instead of living for the runner's lifetime.
|
// working the moment the job ends instead of living for the runner's lifetime.
|
||||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
func TestHandler_UnregisterRevokes(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
unregister := handler.RegisterJob("tmp-token", testRepo)
|
unregister := handler.RegisterJob("tmp-token", JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
||||||
@@ -917,11 +1175,11 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
// invisible to queries scoped to repoB.
|
// invisible to queries scoped to repoB.
|
||||||
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("token-a", "owner/repoA")
|
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("token-b", "owner/repoB")
|
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "shared-key"
|
key := "shared-key"
|
||||||
@@ -983,10 +1241,10 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
// working after artifactURLTTL even if the bearer token is still registered.
|
// working after artifactURLTTL even if the bearer token is still registered.
|
||||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
func TestHandler_ArtifactSignature(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
|
|
||||||
@@ -998,7 +1256,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("tampered signature", func(t *testing.T) {
|
t.Run("tampered signature", func(t *testing.T) {
|
||||||
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
good := signArtifactURL(handler, 1)
|
||||||
bad := good[:len(good)-4] + "dead"
|
bad := good[:len(good)-4] + "dead"
|
||||||
resp, err := testClient.Get(bad)
|
resp, err := testClient.Get(bad)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1007,7 +1265,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("expired signature", func(t *testing.T) {
|
t.Run("expired signature", func(t *testing.T) {
|
||||||
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
|
expired := handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))
|
||||||
resp, err := testClient.Get(expired)
|
resp, err := testClient.Get(expired)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -1016,10 +1274,10 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("signature from a different server", func(t *testing.T) {
|
t.Run("signature from a different server", func(t *testing.T) {
|
||||||
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
||||||
other, err := StartHandler(dir2, "", 0, "", nil)
|
other, err := StartHandler(Options{Dir: dir2})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer other.Close()
|
defer other.Close()
|
||||||
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
otherURL := signArtifactURL(other, 1)
|
||||||
// Rewrite the host so the request still lands on our handler, but
|
// Rewrite the host so the request still lands on our handler, but
|
||||||
// the signature was computed with a different secret.
|
// the signature was computed with a different secret.
|
||||||
parts := strings.SplitN(otherURL, apiPath, 2)
|
parts := strings.SplitN(otherURL, apiPath, 2)
|
||||||
@@ -1038,13 +1296,13 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
|
||||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
first, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||||
sig := first.computeSignature("", 42, exp)
|
sig := first.computeSignature("", 42, exp)
|
||||||
require.NoError(t, first.Close())
|
require.NoError(t, first.Close())
|
||||||
|
|
||||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
second, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer second.Close()
|
defer second.Close()
|
||||||
|
|
||||||
@@ -1056,10 +1314,10 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
|||||||
// the auth refactor.
|
// the auth refactor.
|
||||||
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "download-key"
|
key := "download-key"
|
||||||
@@ -1096,12 +1354,12 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
// (restart mid-task, retry), which must not kill the live job's auth.
|
// (restart mid-task, retry), which must not kill the live job's auth.
|
||||||
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
first := handler.RegisterJob("shared", testRepo)
|
first := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
|
||||||
second := handler.RegisterJob("shared", testRepo)
|
second := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
@@ -1125,14 +1383,14 @@ func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
|||||||
|
|
||||||
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
||||||
// another repo's entry. Two repos reserve the same (key, version); after the
|
// another repo's entry. Two repos reserve the same (key, version); after the
|
||||||
// keepOld window, GC must keep the one from each repo.
|
// inUseGrace window, GC must keep the one from each repo.
|
||||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("tok-a", "owner/repoA")
|
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("tok-b", "owner/repoB")
|
handler.RegisterJob("tok-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
|
||||||
key := "shared-dedup-key"
|
key := "shared-dedup-key"
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
@@ -1142,7 +1400,7 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
stale := time.Now().Add(-keepOld - time.Minute).Unix()
|
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
||||||
require.NoError(t, insertCache(db, a))
|
require.NoError(t, insertCache(db, a))
|
||||||
@@ -1179,7 +1437,7 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
// register/revoke when the feature is off.
|
// register/revoke when the feature is off.
|
||||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1197,7 +1455,7 @@ func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
|||||||
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
const secret = "internal-secret"
|
const secret = "internal-secret"
|
||||||
handler, err := StartHandler(dir, "", 0, secret, nil)
|
handler, err := StartHandler(Options{Dir: dir, InternalSecret: secret})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
// 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
|
// Both API versions are served from the same store, so a repository keeps its cache
|
||||||
// when a workflow moves between action versions.
|
// 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 (
|
const (
|
||||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if existing != nil {
|
} 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)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,8 +97,8 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +138,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
// int64 fields travel as strings in the proto JSON mapping.
|
// int64 fields travel as strings in the proto JSON mapping.
|
||||||
"entryId": strconv.FormatUint(cache.ID, 10),
|
"entry_id": strconv.FormatUint(cache.ID, 10),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +168,9 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"matchedKey": cache.Key,
|
"matched_key": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +213,8 @@ func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params ht
|
|||||||
return
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -45,6 +45,11 @@ func putBlob(t *testing.T, url string, content []byte) int {
|
|||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
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
|
return resp.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,16 +66,6 @@ func getURL(t *testing.T, url string) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTestHandler(t *testing.T) *Handler {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = handler.Close() })
|
|
||||||
handler.RegisterJob(testToken, testRepo)
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||||
// with the upload URL it used.
|
// with the upload URL it used.
|
||||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||||
@@ -78,7 +73,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
|
|||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||||
require.Equal(t, true, created["ok"])
|
require.Equal(t, true, created["ok"])
|
||||||
uploadURL, _ = created["signedUploadUrl"].(string)
|
uploadURL, _ = created["signed_upload_url"].(string)
|
||||||
require.NotEmpty(t, uploadURL)
|
require.NotEmpty(t, uploadURL)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||||
|
|
||||||
@@ -92,7 +87,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
|
|||||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||||
// or to replace a finalized entry.
|
// or to replace a finalized entry.
|
||||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
content := []byte("the cached archive")
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
@@ -100,7 +95,7 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||||
require.Equal(t, true, finalized["ok"])
|
require.Equal(t, true, finalized["ok"])
|
||||||
assert.NotEmpty(t, finalized["entryId"])
|
assert.NotEmpty(t, finalized["entry_id"])
|
||||||
|
|
||||||
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
// 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.
|
// and it is an upload URL only: nothing reads a blob back through it.
|
||||||
@@ -112,8 +107,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "deps-v1", got["matchedKey"])
|
assert.Equal(t, "deps-v1", got["matched_key"])
|
||||||
downloadURL, _ := got["signedDownloadUrl"].(string)
|
downloadURL, _ := got["signed_download_url"].(string)
|
||||||
require.NotEmpty(t, downloadURL)
|
require.NotEmpty(t, downloadURL)
|
||||||
assert.Equal(t, content, getURL(t, downloadURL))
|
assert.Equal(t, content, getURL(t, downloadURL))
|
||||||
}
|
}
|
||||||
@@ -121,10 +116,10 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
// 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.
|
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
uploadURL, _ := created["signedUploadUrl"].(string)
|
uploadURL, _ := created["signed_upload_url"].(string)
|
||||||
require.NotEmpty(t, uploadURL)
|
require.NotEmpty(t, uploadURL)
|
||||||
|
|
||||||
blocks := map[string][]byte{}
|
blocks := map[string][]byte{}
|
||||||
@@ -154,11 +149,11 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
|
|
||||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
assert.Equal(t, "hello world!", string(getURL(t, got["signed_download_url"].(string))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||||
require.Equal(t, true, saved["ok"])
|
require.Equal(t, true, saved["ok"])
|
||||||
|
|
||||||
@@ -175,7 +170,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||||
})
|
})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "deps-abc", got["matchedKey"])
|
assert.Equal(t, "deps-abc", got["matched_key"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +185,19 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
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"})
|
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||||
require.Equal(t, true, reserved["ok"])
|
require.Equal(t, true, reserved["ok"])
|
||||||
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
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) {
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
@@ -203,7 +210,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
// The size the client declares is what Commit validates the assembled archive against.
|
// 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) {
|
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"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
|
||||||
|
|
||||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||||
@@ -227,7 +234,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
|
|
||||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
// 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) {
|
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||||
handler.RegisterJob("other-runtime-token", "other/repo")
|
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
|
||||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||||
|
|
||||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -143,9 +143,13 @@ func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
|||||||
http.ServeFile(w, r, name)
|
http.ServeFile(w, r, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Remove(id uint64) {
|
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
|
||||||
_ = os.Remove(s.filename(id))
|
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
|
||||||
_ = os.RemoveAll(s.tempDir(id))
|
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 {
|
func (s *Storage) filename(id uint64) string {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+15
-2
@@ -345,6 +345,16 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
return fetchOptions, pullOptions
|
return fetchOptions, pullOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// staleRefreshErr reports why a failed refresh must abort: the resolve and
|
||||||
|
// checkout that follow are local and succeed on a cancelled context, which
|
||||||
|
// would hand back the cached revision as if it were fresh.
|
||||||
|
func staleRefreshErr(ctx context.Context, err error) error {
|
||||||
|
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -385,7 +395,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
if !isOfflineMode {
|
||||||
err = r.Fetch(&fetchOptions)
|
err = r.FetchContext(ctx, &fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -454,9 +464,12 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
switch {
|
switch {
|
||||||
case !isOfflineMode && !shallow:
|
case !isOfflineMode && !shallow:
|
||||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
// 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)
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
}
|
}
|
||||||
|
if err := staleRefreshErr(ctx, err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
case isOfflineMode && reused:
|
case isOfflineMode && reused:
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,24 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
gogit "github.com/go-git/go-git/v5"
|
||||||
|
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -610,3 +616,55 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}()
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
"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.
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
@@ -57,6 +59,32 @@ type FileEntry struct {
|
|||||||
Body string
|
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
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
@@ -65,6 +93,8 @@ type Container interface {
|
|||||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
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
|
Pull(forcePull bool) common.Executor
|
||||||
Start(attach bool) common.Executor
|
Start(attach bool) common.Executor
|
||||||
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||||
|
|||||||
+15
-11
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/9a471180cb7d39c236d090399a9d362c3f5a8ebd/cli/command/container/opts.go
|
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
||||||
// appended with license information.
|
// * appended with license information
|
||||||
|
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
// See DOCKER_LICENSE for the full license text.
|
// See DOCKER_LICENSE for the full license text.
|
||||||
@@ -30,6 +32,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
@@ -380,7 +383,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
var binds []string
|
var binds []string
|
||||||
volumes := copts.volumes.GetMap()
|
volumes := copts.volumes.GetMap()
|
||||||
// add any bind targets to the list of container volumes
|
// add any bind targets to the list of container volumes
|
||||||
for bind := range copts.volumes.GetMap() {
|
for bind := range volumes {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := loader.ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// collect all the environment variables for the container
|
||||||
envVariables, err := opts.ReadKVEnvStrings(copts.envFile.GetSlice(), copts.env.GetSlice())
|
envVariables, err := opts.ReadKVEnvStrings(copts.envFile.GetSlice(), copts.env.GetSlice())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("--env-file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collect all the labels for the container
|
// collect all the labels for the container
|
||||||
labels, err := opts.ReadKVStrings(copts.labelsFile.GetSlice(), copts.labels.GetSlice())
|
labels, err := opts.ReadKVStrings(copts.labelsFile.GetSlice(), copts.labels.GetSlice())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("--label-file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pidMode := container.PidMode(copts.pidMode)
|
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.
|
// invalidParameter wraps an error to indicate it was caused by invalid input.
|
||||||
// This is a local replacement for docker/docker/errdefs.InvalidParameter.
|
// This is a copy of docker/cli's cli/command/container/errors.go, which is not importable.
|
||||||
type invalidParameterError struct{ error }
|
type invalidParameterErr struct{ error }
|
||||||
|
|
||||||
func (e invalidParameterError) InvalidParameter() {}
|
func (invalidParameterErr) InvalidParameter() {}
|
||||||
|
func (e invalidParameterErr) Unwrap() error { return e.error }
|
||||||
|
|
||||||
func invalidParameter(err error) error {
|
func invalidParameter(err error) error {
|
||||||
if err == nil {
|
if err == nil || cerrdefs.IsInvalidArgument(err) {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
return invalidParameterError{err}
|
return invalidParameterErr{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
||||||
|
|||||||
+378
-220
@@ -2,20 +2,22 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/9ac8584acfd501c3f4da0e845e3a40ed15c85041/cli/command/container/opts_test.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * commented out case 'invalid-mixed-network-types' in test TestParseNetworkConfig
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
// See DOCKER_LICENSE for the full license text.
|
// See DOCKER_LICENSE for the full license text.
|
||||||
//
|
//
|
||||||
|
|
||||||
//nolint:depguard,gocritic // verbatim copy from docker/cli tests
|
//nolint:gocritic // verbatim copy from docker/cli tests
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -23,18 +25,23 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
networktypes "github.com/moby/moby/api/types/network"
|
networktypes "github.com/moby/moby/api/types/network"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
is "gotest.tools/v3/assert/cmp"
|
is "gotest.tools/v3/assert/cmp"
|
||||||
"gotest.tools/v3/skip"
|
"gotest.tools/v3/skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func mustParseMAC(s string) networktypes.HardwareAddr {
|
||||||
|
mac, err := net.ParseMAC(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return networktypes.HardwareAddr(mac)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateAttach(t *testing.T) {
|
func TestValidateAttach(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"stdin",
|
"stdin",
|
||||||
@@ -64,12 +71,12 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network
|
|||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
// TODO: fix tests to accept ContainerConfig
|
// TODO(dnephin): fix tests to accept ContainerConfig; see https://github.com/moby/moby/pull/31621
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
containerCfg, err := parse(flags, copts, runtime.GOOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
return containerConfig.Config, containerConfig.HostConfig, containerConfig.NetworkingConfig, err
|
return containerCfg.Config, containerCfg.HostConfig, containerCfg.NetworkingConfig, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||||
@@ -82,20 +89,81 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
|
|
||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
return config, hostConfig, networkingConfig
|
return config, hostConfig, nwConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunLinks(t *testing.T) {
|
func TestParseRunLinks(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
tests := []struct {
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
name string
|
||||||
|
input string
|
||||||
|
expHostConfigLinks []string
|
||||||
|
expNetConfigLinks map[string][]string
|
||||||
|
}{
|
||||||
|
// Default bridge - legacy links ...
|
||||||
|
{
|
||||||
|
name: "default/onelink",
|
||||||
|
input: "--link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/twolinks",
|
||||||
|
input: "--link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bridge/onelink",
|
||||||
|
input: "--network bridge --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
// expNetConfigLinks - no EndpointsConfig is created for a single named network with no options set.
|
||||||
|
// See the "For backward compatibility" comment in parseNetworkOpts().
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/nolinks",
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
|
||||||
|
// User-defined bridge - links become DNS aliases ...
|
||||||
|
{
|
||||||
|
name: "userdefnet/onelink",
|
||||||
|
input: "--network userdefnet --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/twolinks",
|
||||||
|
input: "--network userdefnet --link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/nolinks",
|
||||||
|
input: "--network userdefnet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Link options are applied to the first network (and there's no "advanced syntax"
|
||||||
|
// link key, like "--network name=userdefnet,link=a:b").
|
||||||
|
name: "links apply to the first network",
|
||||||
|
input: "--network userdefnet --link a:b --network bar --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}, "bar": nil},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
for _, tc := range tests {
|
||||||
}
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
||||||
|
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
||||||
|
for netName, expLinks := range tc.expNetConfigLinks {
|
||||||
|
nc, ok := netConfig.EndpointsConfig[netName]
|
||||||
|
assert.Assert(t, ok)
|
||||||
|
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,37 +362,7 @@ func compareRandomizedStrings(a, b, c, d string) error {
|
|||||||
if a == d && b == c {
|
if a == d && b == c {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Errorf("strings don't match")
|
return errors.New("strings don't match")
|
||||||
}
|
|
||||||
|
|
||||||
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
port, err := networktypes.ParsePort(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse network port %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddr(t *testing.T, value string) netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse address %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addrs := make([]netip.Addr, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
addrs = append(addrs, mustAddr(t, value))
|
|
||||||
}
|
|
||||||
return addrs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple parse with MacAddress validation
|
// Simple parse with MacAddress validation
|
||||||
@@ -334,10 +372,11 @@ func TestParseWithMacAddress(t *testing.T) {
|
|||||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||||
}
|
}
|
||||||
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
|
_, hostConfig, nwConfig := mustParse(t, validMacAddress)
|
||||||
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
|
defaultNw := hostConfig.NetworkMode.NetworkName()
|
||||||
assert.Check(t, endpoint != nil)
|
if nwConfig.EndpointsConfig[defaultNw].MacAddress.String() != "92:d0:c6:0a:29:33" {
|
||||||
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
|
t.Fatalf("Expected the default endpoint to have the MacAddress '92:d0:c6:0a:29:33' set, got '%v'", nwConfig.EndpointsConfig[defaultNw].MacAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||||
@@ -408,93 +447,144 @@ func TestParseHostnameDomainname(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithExpose(t *testing.T) {
|
func TestParseWithExpose(t *testing.T) {
|
||||||
invalids := []string{
|
t.Run("invalid", func(t *testing.T) {
|
||||||
":",
|
tests := map[string]string{
|
||||||
"8080:9090",
|
":": `invalid range format for --expose: invalid start port ':': invalid syntax`,
|
||||||
"/tcp",
|
"8080:9090": `invalid range format for --expose: invalid start port '8080:9090': invalid syntax`,
|
||||||
"/udp",
|
"/tcp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN/tcp",
|
"/udp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN-NaN/tcp",
|
"NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"8080-NaN/tcp",
|
"NaN-NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"1234567890-8080/tcp",
|
"8080-NaN/tcp": `invalid range format for --expose: invalid end port 'NaN': invalid syntax`,
|
||||||
}
|
"1234567890-8080/tcp": `invalid range format for --expose: invalid start port '1234567890': value out of range`,
|
||||||
valids := map[string][]nat.Port{
|
|
||||||
"8080/tcp": {"8080/tcp"},
|
|
||||||
"8080/udp": {"8080/udp"},
|
|
||||||
"8080/ncp": {"8080/ncp"},
|
|
||||||
"8080-8080/udp": {"8080/udp"},
|
|
||||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
|
||||||
}
|
|
||||||
for _, expose := range invalids {
|
|
||||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
|
|
||||||
t.Fatalf("Expected error with '--expose=%v', got none", expose)
|
|
||||||
}
|
}
|
||||||
}
|
for expose, expectedError := range tests {
|
||||||
for expose, exposedPorts := range valids {
|
t.Run(expose, func(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
if err != nil {
|
assert.Error(t, err, expectedError)
|
||||||
t.Fatal(err)
|
})
|
||||||
}
|
}
|
||||||
if len(config.ExposedPorts) != len(exposedPorts) {
|
})
|
||||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
tests := map[string][]networktypes.Port{
|
||||||
|
"8080/tcp": {networktypes.MustParsePort("8080/tcp")},
|
||||||
|
"8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080/ncp": {networktypes.MustParsePort("8080/ncp")},
|
||||||
|
"8080-8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080-8082/tcp": {networktypes.MustParsePort("8080/tcp"), networktypes.MustParsePort("8081/tcp"), networktypes.MustParsePort("8082/tcp")},
|
||||||
}
|
}
|
||||||
for _, port := range exposedPorts {
|
for expose, exposedPorts := range tests {
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
t.Run(expose, func(t *testing.T) {
|
||||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
}
|
assert.NilError(t, err)
|
||||||
|
for _, port := range exposedPorts {
|
||||||
|
_, ok := config.ExposedPorts[port]
|
||||||
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
// Merge with actual published port
|
|
||||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
t.Run("merge with published", func(t *testing.T) {
|
||||||
if err != nil {
|
// Merge with actual published port
|
||||||
t.Fatal(err)
|
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||||
}
|
assert.NilError(t, err)
|
||||||
if len(config.ExposedPorts) != 2 {
|
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
||||||
t.Fatalf("Expected 2 exposed ports, got %v", config.ExposedPorts)
|
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||||
}
|
for _, port := range ports {
|
||||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
_, ok := config.ExposedPorts[port]
|
||||||
for _, port := range ports {
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
|
||||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDevice(t *testing.T) {
|
func TestParseDevice(t *testing.T) {
|
||||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||||
valids := map[string]container.DeviceMapping{
|
testCases := []struct {
|
||||||
"/dev/snd": {
|
devices []string
|
||||||
PathOnHost: "/dev/snd",
|
deviceMapping *container.DeviceMapping
|
||||||
PathInContainer: "/dev/snd",
|
deviceRequests []container.DeviceRequest
|
||||||
CgroupPermissions: "rwm",
|
}{
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:rw": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:rw"},
|
||||||
PathInContainer: "/dev/snd",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rw",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:/something": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:/something"},
|
||||||
PathInContainer: "/something",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rwm",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/dev/snd:/something:rw": {
|
{
|
||||||
PathOnHost: "/dev/snd",
|
devices: []string{"/dev/snd:/something:rw"},
|
||||||
PathInContainer: "/something",
|
deviceMapping: &container.DeviceMapping{
|
||||||
CgroupPermissions: "rw",
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name"},
|
||||||
|
deviceMapping: nil,
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name", "/dev/snd:/something:rw"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for device, deviceMapping := range valids {
|
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--device=%v", device), "img", "cmd"})
|
for _, tc := range testCases {
|
||||||
if err != nil {
|
t.Run(fmt.Sprintf("%s", tc.devices), func(t *testing.T) {
|
||||||
t.Fatal(err)
|
var args []string
|
||||||
}
|
for _, d := range tc.devices {
|
||||||
if len(hostconfig.Devices) != 1 {
|
args = append(args, fmt.Sprintf("--device=%v", d))
|
||||||
t.Fatalf("Expected 1 devices, got %v", hostconfig.Devices)
|
}
|
||||||
}
|
args = append(args, "img", "cmd")
|
||||||
if hostconfig.Devices[0] != deviceMapping {
|
|
||||||
t.Fatalf("Expected %v, got %v", deviceMapping, hostconfig.Devices)
|
_, hostconfig, _, err := parseRun(args)
|
||||||
}
|
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
if tc.deviceMapping != nil {
|
||||||
|
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
||||||
|
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Check(t, is.Len(hostconfig.Devices, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,23 +663,23 @@ func TestParseDeviceByServerOS(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseNetworkConfig(t *testing.T) {
|
func TestParseNetworkConfig(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
flags []string
|
flags []string
|
||||||
expected map[string]*networktypes.EndpointSettings
|
expected map[string]*networktypes.EndpointSettings
|
||||||
expectedCfg container.HostConfig
|
expectedHostCfg container.HostConfig
|
||||||
expectedErr string
|
expectedErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single-network-legacy",
|
name: "single-network-legacy",
|
||||||
flags: []string{"--network", "net1"},
|
flags: []string{"--network", "net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced",
|
name: "single-network-advanced",
|
||||||
flags: []string{"--network", "name=net1"},
|
flags: []string{"--network", "name=net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-legacy-with-options",
|
name: "single-network-legacy-with-options",
|
||||||
@@ -607,15 +697,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-network-advanced-mixed",
|
name: "multiple-network-advanced-mixed",
|
||||||
@@ -631,14 +721,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"--network-alias", "web2",
|
"--network-alias", "web2",
|
||||||
"--network", "net2",
|
"--network", "net2",
|
||||||
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
||||||
|
"--network", "name=net4,mac-address=02:32:1c:23:00:04,link-local-ip=169.254.169.254",
|
||||||
},
|
},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{"field1": "value1"},
|
DriverOpts: map[string]string{"field1": "value1"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
@@ -647,17 +738,23 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"net3": {
|
"net3": {
|
||||||
DriverOpts: map[string]string{"field3": "value3"},
|
DriverOpts: map[string]string{"field3": "value3"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web3"},
|
Aliases: []string{"web3"},
|
||||||
},
|
},
|
||||||
|
"net4": {
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.169.254")},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced-with-options",
|
name: "single-network-advanced-with-options",
|
||||||
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822"},
|
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822,mac-address=02:32:1c:23:00:04"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{
|
DriverOpts: map[string]string{
|
||||||
@@ -665,19 +762,31 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"field2": "value2",
|
"field2": "value2",
|
||||||
},
|
},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-networks",
|
name: "multiple-networks",
|
||||||
flags: []string{"--network", "net1", "--network", "name=net2"},
|
flags: []string{"--network", "net1", "--network", "name=net2"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "advanced-options-with-standalone-mac-address-flag",
|
||||||
|
flags: []string{"--network=name=net1,alias=foobar", "--mac-address", "52:0f:f3:dc:50:10"},
|
||||||
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
|
"net1": {
|
||||||
|
Aliases: []string{"foobar"},
|
||||||
|
MacAddress: mustParseMAC("52:0f:f3:dc:50:10"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "conflict-network",
|
name: "conflict-network",
|
||||||
@@ -699,13 +808,26 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
||||||
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
||||||
},
|
},
|
||||||
// case is skipped as it fails w/o any change
|
{
|
||||||
//
|
name: "invalid-mixed-network-types",
|
||||||
//{
|
flags: []string{"--network", "name=host", "--network", "net1"},
|
||||||
// name: "invalid-mixed-network-types",
|
expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
||||||
// flags: []string{"--network", "name=host", "--network", "net1"},
|
},
|
||||||
// expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
{
|
||||||
//},
|
name: "conflict-options-link-local-ip",
|
||||||
|
flags: []string{"--network", "name=net1,link-local-ip=169.254.169.254", "--link-local-ip", "169.254.10.8"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflict-options-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=02:32:1c:23:00:04", "--mac-address", "02:32:1c:23:00:04"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --mac-address and per-network MAC address`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=foobar"},
|
||||||
|
expectedErr: "foobar is not a valid mac address",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -718,10 +840,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
||||||
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
|
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
||||||
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,42 +890,84 @@ func TestRunFlagsParseShmSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicy(t *testing.T) {
|
func TestParseRestartPolicy(t *testing.T) {
|
||||||
invalids := map[string]string{
|
tests := []struct {
|
||||||
"always:2:3": "invalid restart policy format: maximum retry count must be an integer",
|
input string
|
||||||
"on-failure:invalid": "invalid restart policy format: maximum retry count must be an integer",
|
expected container.RestartPolicy
|
||||||
}
|
expectedErr string
|
||||||
valids := map[string]container.RestartPolicy{
|
}{
|
||||||
"": {},
|
{
|
||||||
"always": {
|
input: "",
|
||||||
Name: "always",
|
|
||||||
MaximumRetryCount: 0,
|
|
||||||
},
|
},
|
||||||
"on-failure:1": {
|
{
|
||||||
Name: "on-failure",
|
input: "no",
|
||||||
MaximumRetryCount: 1,
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyDisabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ":1",
|
||||||
|
expectedErr: "invalid restart policy format: no policy provided before colon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always:2:3",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:1",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyOnFailure,
|
||||||
|
MaximumRetryCount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyUnlessStopped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unknown / invalid combinations: validation is handled by the daemon>
|
||||||
|
{
|
||||||
|
input: "anything:123",
|
||||||
|
expected: container.RestartPolicy{Name: "anything", MaximumRetryCount: 123},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "negative:-123",
|
||||||
|
expected: container.RestartPolicy{Name: "negative", MaximumRetryCount: -123},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for restart, expectedError := range invalids {
|
for _, tc := range tests {
|
||||||
if _, _, _, err := parseRun([]string{"--restart=" + restart, "img", "cmd"}); err == nil || err.Error() != expectedError {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
t.Fatalf("Expected an error with message '%v' for %v, got %v", expectedError, restart, err)
|
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||||
}
|
if tc.expectedErr != "" {
|
||||||
}
|
assert.Check(t, is.Error(err, tc.expectedErr))
|
||||||
for restart, expected := range valids {
|
assert.Check(t, is.Nil(hostConfig))
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--restart=%v", restart), "img", "cmd"})
|
} else {
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
||||||
}
|
}
|
||||||
if hostconfig.RestartPolicy != expected {
|
})
|
||||||
t.Fatalf("Expected %v, got %v", expected, hostconfig.RestartPolicy)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
if err == nil {
|
const expected = "conflicting options: cannot specify both --restart and --rm"
|
||||||
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
|
assert.Check(t, is.Error(err, expected))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHealth(t *testing.T) {
|
func TestParseHealth(t *testing.T) {
|
||||||
@@ -841,8 +1003,8 @@ func TestParseHealth(t *testing.T) {
|
|||||||
checkError("--no-healthcheck conflicts with --health-* options",
|
checkError("--no-healthcheck conflicts with --health-* options",
|
||||||
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
||||||
|
|
||||||
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "img", "cmd")
|
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd")
|
||||||
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second {
|
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second {
|
||||||
t.Fatalf("--health-*: got %#v", health)
|
t.Fatalf("--health-*: got %#v", health)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -863,13 +1025,13 @@ func TestParseLoggingOpts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--env-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--env-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// env ko
|
// env ko
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// env ok
|
// env ok
|
||||||
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
||||||
@@ -905,7 +1067,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UTF16 with BOM
|
// UTF16 with BOM
|
||||||
e := "invalid env file"
|
e := "invalid utf8 bytes at line"
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||||
}
|
}
|
||||||
@@ -916,13 +1078,13 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--label-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--label-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// label ko
|
// label ko
|
||||||
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// label ok
|
// label ok
|
||||||
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
||||||
@@ -943,12 +1105,8 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy
|
|||||||
|
|
||||||
func TestParseEntryPoint(t *testing.T) {
|
func TestParseEntryPoint(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
||||||
}
|
|
||||||
if len(config.Entrypoint) != 1 && config.Entrypoint[0] != "anything" {
|
|
||||||
t.Fatalf("Expected entrypoint 'anything', got %v", config.Entrypoint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDevice(t *testing.T) {
|
func TestValidateDevice(t *testing.T) {
|
||||||
@@ -995,10 +1153,8 @@ func TestValidateDevice(t *testing.T) {
|
|||||||
for path, expectedError := range invalid {
|
for path, expectedError := range invalid {
|
||||||
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
||||||
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
||||||
} else {
|
} else if err.Error() != expectedError {
|
||||||
if err.Error() != expectedError {
|
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
||||||
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1073,10 +1229,12 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
|||||||
if invalidParameter(nil) != nil {
|
if invalidParameter(nil) != nil {
|
||||||
t.Fatal("invalidParameter(nil) should be nil")
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
}
|
}
|
||||||
err = invalidParameter(errors.New("bad input"))
|
cause := errors.New("bad input")
|
||||||
assert.Assert(t, err != nil)
|
err = invalidParameter(cause)
|
||||||
var invalid interface{ InvalidParameter() }
|
var invalid interface{ InvalidParameter() }
|
||||||
assert.Assert(t, errors.As(err, &invalid))
|
assert.Assert(t, errors.As(err, &invalid))
|
||||||
|
assert.Assert(t, errors.Is(err, cause))
|
||||||
|
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
|
|||||||
+138
-120
@@ -14,7 +14,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -27,7 +26,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
"dario.cat/mergo"
|
||||||
"github.com/Masterminds/semver"
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
"github.com/docker/cli/cli/connhelper"
|
||||||
@@ -42,6 +40,7 @@ import (
|
|||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/moby/moby/client/pkg/versions"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -89,22 +88,14 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportsContainerImagePlatform returns true if the underlying Docker server
|
// supportsContainerImagePlatform reports whether the Docker server API version
|
||||||
// API version is 1.41 and beyond
|
// is 1.41 and beyond
|
||||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
|
||||||
logger := common.Logger(ctx)
|
|
||||||
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Panicf("Failed to get Docker API Version: %s", err)
|
return false, fmt.Errorf("get docker API version: %w", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
sv, err := semver.NewVersion(ver.APIVersion)
|
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41"), nil
|
||||||
if err != nil {
|
|
||||||
logger.Panicf("Failed to unmarshal Docker Version: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
constraint, _ := semver.NewConstraint(">= 1.41")
|
|
||||||
return constraint.Check(sv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
||||||
@@ -206,6 +197,109 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
|
|||||||
return result.Content, nil
|
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 {
|
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun)
|
return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun)
|
||||||
}
|
}
|
||||||
@@ -351,10 +445,10 @@ func (cr *containerReference) Close() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// missingContainerError is the shared "container X does not exist" error
|
// missingContainerError is the shared "container X does not exist" error used by ops that
|
||||||
// used by ops that need a live cr.id.
|
// 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 {
|
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 {
|
func (cr *containerReference) find() common.Executor {
|
||||||
@@ -588,11 +682,17 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var platSpecs *specs.Platform
|
var platSpecs *specs.Platform
|
||||||
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
|
if cr.input.Platform != "" {
|
||||||
platSpecs, err = parsePlatform(cr.input.Platform)
|
// Dropping the platform silently would build for the host arch.
|
||||||
|
supported, err := supportsContainerImagePlatform(ctx, cr.cli)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if supported {
|
||||||
|
if platSpecs, err = parsePlatform(cr.input.Platform); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
@@ -745,7 +845,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
}
|
}
|
||||||
defer resp.Close()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -803,7 +903,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
|||||||
return cr.tryReadID("-g", func(id int) { cr.GID = id })
|
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)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||||
@@ -811,28 +911,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
cmdResponse := make(chan error, 1)
|
cmdResponse := make(chan error, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var outWriter io.Writer
|
cmdResponse <- cr.copyOutput(resp.Reader)
|
||||||
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
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -865,59 +944,24 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mkdirInContainer creates containerPath and returns it with the symlinked components
|
|
||||||
// replaced by the targets the daemon reports for them. Docker 29.7 rejects tar entries
|
|
||||||
// traversing a symlink to an absolute target, like the "/var/run" of most images, with
|
|
||||||
// "path escapes from parent", and not every daemon creates the implied parents of a
|
|
||||||
// directory entry, so one entry per missing component is extracted at the deepest
|
|
||||||
// existing ancestor.
|
|
||||||
// WORKAROUND: https://github.com/moby/moby/issues/53258
|
|
||||||
func (cr *containerReference) mkdirInContainer(ctx context.Context, containerPath string) (string, error) {
|
|
||||||
parts := strings.Split(strings.Trim(path.Clean(containerPath), "/"), "/")
|
|
||||||
existing := "/"
|
|
||||||
for i, part := range parts {
|
|
||||||
if part == "" {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
stat, err := cr.cli.ContainerStatPath(ctx, cr.id, client.ContainerStatPathOptions{Path: path.Join(existing, part)})
|
|
||||||
if err != nil {
|
|
||||||
// nothing below exists either, so create the remaining components
|
|
||||||
return path.Join(existing, path.Join(parts[i:]...)), cr.mkdirEntries(ctx, existing, parts[i:])
|
|
||||||
}
|
|
||||||
existing = path.Join(existing, part)
|
|
||||||
if target := stat.Stat.LinkTarget; target != "" {
|
|
||||||
if !path.IsAbs(target) {
|
|
||||||
target = path.Join(path.Dir(existing), target)
|
|
||||||
}
|
|
||||||
existing = target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) mkdirEntries(ctx context.Context, destPath string, missing []string) error {
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
for i := range missing {
|
|
||||||
_ = tw.WriteHeader(&tar.Header{
|
|
||||||
Name: path.Join(missing[:i+1]...),
|
|
||||||
Mode: 0o777,
|
|
||||||
Typeflag: tar.TypeDir,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
tw.Close()
|
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
|
||||||
DestinationPath: destPath,
|
|
||||||
Content: buf,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
return cr.missingContainerError("copy to %s", destPath)
|
return cr.missingContainerError("copy to %s", destPath)
|
||||||
}
|
}
|
||||||
destPath, err := cr.mkdirInContainer(ctx, destPath)
|
// 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: strings.TrimPrefix(destPath, "/"),
|
||||||
|
Mode: 0o777,
|
||||||
|
Typeflag: tar.TypeDir,
|
||||||
|
})
|
||||||
|
tw.Close()
|
||||||
|
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||||
|
DestinationPath: "/",
|
||||||
|
Content: buf,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -942,10 +986,6 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||||
}
|
}
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
dstPath, err := cr.mkdirInContainer(ctx, dstPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to mkdir to copy directory to container: %w", err)
|
|
||||||
}
|
|
||||||
tarFile, err := os.CreateTemp("", "act")
|
tarFile, err := os.CreateTemp("", "act")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1067,33 +1107,11 @@ func (cr *containerReference) attach() common.Executor {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to attach to container: %w", err)
|
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{})
|
done := make(chan struct{})
|
||||||
cr.attachDone = done
|
cr.attachDone = done
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
var copyErr error
|
if copyErr := cr.copyOutput(out.Reader); copyErr != nil {
|
||||||
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 {
|
|
||||||
common.Logger(ctx).Error(copyErr)
|
common.Logger(ctx).Error(copyErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -78,6 +79,11 @@ type mockDockerClient struct {
|
|||||||
mock.Mock
|
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) {
|
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||||
@@ -93,11 +99,6 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
|
|||||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
|
||||||
args := m.Called(ctx, containerID, opts)
|
|
||||||
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||||
args := m.Called(ctx, containerID, opts)
|
args := m.Called(ctx, containerID, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||||
@@ -341,37 +342,52 @@ func TestDockerWaitFailure(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
// when they are a symlink, everything else does not exist.
|
ctx := context.Background()
|
||||||
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
|
||||||
for containerPath, target := range existing {
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
return opts.DestinationPath == "/" && opts.Content != nil
|
||||||
|
})).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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
|
||||||
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
// Docker 29.5+ rejects absolute names in the mkdir tarball with
|
||||||
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
// "path escapes from parent", since it is extracted relative to "/".
|
||||||
func TestDockerCopyTarStream(t *testing.T) {
|
func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
var mkdirNames []string
|
var mkdirNames []string
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
if opts.DestinationPath != "/run" || opts.Content == nil {
|
if opts.DestinationPath != "/" || opts.Content == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tr := tar.NewReader(opts.Content)
|
tr := tar.NewReader(opts.Content)
|
||||||
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
mkdirNames = append(mkdirNames, hdr.Name)
|
mkdirNames = append(mkdirNames, hdr.Name)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
@@ -382,45 +398,58 @@ func TestDockerCopyTarStream(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||||
assert.Equal(t, []string{"act"}, mkdirNames)
|
assert.Equal(t, []string{"var/run/act"}, mkdirNames)
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
merr := errors.New("Failure")
|
||||||
for _, testCase := range []struct {
|
|
||||||
name string
|
|
||||||
mkdirErr error
|
|
||||||
copyErr error
|
|
||||||
}{
|
|
||||||
{"mkdir", merr, nil},
|
|
||||||
{"copy content", nil, merr},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
return opts.DestinationPath == "/" && opts.Content != nil
|
||||||
return opts.DestinationPath == "/var/run" && opts.Content != nil
|
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
|
cr := &containerReference{
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
id: "123",
|
||||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
cli: client,
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
input: &NewContainerInput{
|
||||||
cr := &containerReference{
|
Image: "image",
|
||||||
id: "123",
|
},
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "image",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
merr := errors.New("Failure")
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/" && opts.Content != nil
|
||||||
|
})).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{}, merr)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||||
@@ -548,7 +577,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
check := func(op string, err error) {
|
check := func(op string, err error) {
|
||||||
t.Helper()
|
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)
|
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))
|
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
@@ -557,6 +586,15 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||||
check("GetContainerArchive", err)
|
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,
|
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
||||||
@@ -602,9 +640,10 @@ func TestDockerCopyToSymlinkPath(t *testing.T) {
|
|||||||
_ = rc.Close()(ctx)
|
_ = rc.Close()(ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
||||||
// exact step that fails on a broken daemon.
|
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
||||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
// step that fails on the broken daemon.
|
||||||
|
err := rc.CopyTarStream(ctx, "/var/run/act/actions/", &bytes.Buffer{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,6 +864,59 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|||||||
assert.Empty(t, hostConf.Binds)
|
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) {
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger, _ := test.NewNullLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
@@ -843,3 +935,15 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||||
assert.Empty(t, hostConf.Mounts)
|
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,13 +8,13 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
|
|||||||
@@ -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) {
|
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
tw := tar.NewWriter(buf)
|
tw := tar.NewWriter(buf)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,644 +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 (
|
|
||||||
"encoding"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/rhysd/actionlint"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EvaluationEnvironment struct {
|
|
||||||
Github *model.GithubContext
|
|
||||||
Env map[string]string
|
|
||||||
Job *model.JobContext
|
|
||||||
Jobs *map[string]*model.WorkflowCallResult
|
|
||||||
Steps map[string]*model.StepResult
|
|
||||||
Runner map[string]any
|
|
||||||
Secrets map[string]string
|
|
||||||
Vars map[string]string
|
|
||||||
Strategy map[string]any
|
|
||||||
Matrix map[string]any
|
|
||||||
Needs map[string]Needs
|
|
||||||
Inputs map[string]any
|
|
||||||
HashFiles func([]reflect.Value) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Needs struct {
|
|
||||||
Outputs map[string]string `json:"outputs"`
|
|
||||||
Result string `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Run *model.Run
|
|
||||||
WorkingDir string
|
|
||||||
Context string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultStatusCheck int
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultStatusCheckNone DefaultStatusCheck = iota
|
|
||||||
DefaultStatusCheckSuccess
|
|
||||||
DefaultStatusCheckAlways
|
|
||||||
DefaultStatusCheckCanceled
|
|
||||||
DefaultStatusCheckFailure
|
|
||||||
)
|
|
||||||
|
|
||||||
func (dsc DefaultStatusCheck) String() string {
|
|
||||||
switch dsc {
|
|
||||||
case DefaultStatusCheckSuccess:
|
|
||||||
return "success"
|
|
||||||
case DefaultStatusCheckAlways:
|
|
||||||
return "always"
|
|
||||||
case DefaultStatusCheckCanceled:
|
|
||||||
return "cancelled"
|
|
||||||
case DefaultStatusCheckFailure:
|
|
||||||
return "failure"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type Interpreter interface {
|
|
||||||
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type interperterImpl struct {
|
|
||||||
env *EvaluationEnvironment
|
|
||||||
config Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
|
||||||
return &interperterImpl{
|
|
||||||
env: env,
|
|
||||||
config: config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
|
||||||
input = strings.TrimPrefix(input, "${{")
|
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
|
||||||
input = "success()"
|
|
||||||
}
|
|
||||||
parser := actionlint.NewExprParser()
|
|
||||||
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone {
|
|
||||||
hasStatusCheckFunction := false
|
|
||||||
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
|
||||||
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
|
||||||
switch strings.ToLower(funcCallNode.Callee) {
|
|
||||||
case "success", "always", "cancelled", "failure":
|
|
||||||
hasStatusCheckFunction = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if !hasStatusCheckFunction {
|
|
||||||
exprNode = &actionlint.LogicalOpNode{
|
|
||||||
Kind: actionlint.LogicalOpNodeKindAnd,
|
|
||||||
Left: &actionlint.FuncCallNode{
|
|
||||||
Callee: defaultStatusCheck.String(),
|
|
||||||
Args: []actionlint.ExprNode{},
|
|
||||||
},
|
|
||||||
Right: exprNode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err2 := impl.evaluateNode(exprNode)
|
|
||||||
|
|
||||||
return result, err2
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
|
||||||
switch node := exprNode.(type) {
|
|
||||||
case *actionlint.VariableNode:
|
|
||||||
return impl.evaluateVariable(node)
|
|
||||||
case *actionlint.BoolNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.NullNode:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
case *actionlint.IntNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.FloatNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.StringNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.IndexAccessNode:
|
|
||||||
return impl.evaluateIndexAccess(node)
|
|
||||||
case *actionlint.ObjectDerefNode:
|
|
||||||
return impl.evaluateObjectDeref(node)
|
|
||||||
case *actionlint.ArrayDerefNode:
|
|
||||||
return impl.evaluateArrayDeref(node)
|
|
||||||
case *actionlint.NotOpNode:
|
|
||||||
return impl.evaluateNot(node)
|
|
||||||
case *actionlint.CompareOpNode:
|
|
||||||
return impl.evaluateCompare(node)
|
|
||||||
case *actionlint.LogicalOpNode:
|
|
||||||
return impl.evaluateLogicalCompare(node)
|
|
||||||
case *actionlint.FuncCallNode:
|
|
||||||
return impl.evaluateFuncCall(node)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
|
||||||
switch strings.ToLower(variableNode.Name) {
|
|
||||||
case "github":
|
|
||||||
return impl.env.Github, nil
|
|
||||||
case "gitea": // compatible with Gitea
|
|
||||||
return impl.env.Github, nil
|
|
||||||
case "env":
|
|
||||||
return impl.env.Env, nil
|
|
||||||
case "job":
|
|
||||||
return impl.env.Job, nil
|
|
||||||
case "jobs":
|
|
||||||
if impl.env.Jobs == nil {
|
|
||||||
return nil, errors.New("Unavailable context: jobs")
|
|
||||||
}
|
|
||||||
return impl.env.Jobs, nil
|
|
||||||
case "steps":
|
|
||||||
return impl.env.Steps, nil
|
|
||||||
case "runner":
|
|
||||||
return impl.env.Runner, nil
|
|
||||||
case "secrets":
|
|
||||||
return impl.env.Secrets, nil
|
|
||||||
case "vars":
|
|
||||||
return impl.env.Vars, nil
|
|
||||||
case "strategy":
|
|
||||||
return impl.env.Strategy, nil
|
|
||||||
case "matrix":
|
|
||||||
return impl.env.Matrix, nil
|
|
||||||
case "needs":
|
|
||||||
return impl.env.Needs, nil
|
|
||||||
case "inputs":
|
|
||||||
return impl.env.Inputs, nil
|
|
||||||
case "infinity":
|
|
||||||
return math.Inf(1), nil
|
|
||||||
case "nan":
|
|
||||||
return math.NaN(), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(indexAccessNode.Index)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
switch rightValue.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
return impl.getPropertyValue(leftValue, rightValue.String())
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
switch leftValue.Kind() {
|
|
||||||
case reflect.Slice:
|
|
||||||
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
|
||||||
switch left.Kind() {
|
|
||||||
case reflect.Pointer:
|
|
||||||
return impl.getPropertyValue(left.Elem(), property)
|
|
||||||
|
|
||||||
case reflect.Struct:
|
|
||||||
leftType := left.Type()
|
|
||||||
for field := range leftType.Fields() {
|
|
||||||
jsonName := field.Tag.Get("json")
|
|
||||||
if jsonName == property {
|
|
||||||
property = field.Name
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
|
||||||
return strings.EqualFold(name, property)
|
|
||||||
})
|
|
||||||
|
|
||||||
if fieldValue.Kind() == reflect.Invalid {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
i := fieldValue.Interface()
|
|
||||||
// The type stepStatus int is an integer, but should be treated as string
|
|
||||||
if m, ok := i.(encoding.TextMarshaler); ok {
|
|
||||||
text, err := m.MarshalText()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return string(text), nil
|
|
||||||
}
|
|
||||||
return i, nil
|
|
||||||
|
|
||||||
case reflect.Map:
|
|
||||||
iter := left.MapRange()
|
|
||||||
|
|
||||||
for iter.Next() {
|
|
||||||
key := iter.Key()
|
|
||||||
|
|
||||||
switch key.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
if strings.EqualFold(key.String(), property) {
|
|
||||||
return impl.getMapValue(iter.Value())
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
case reflect.Slice:
|
|
||||||
var values []any
|
|
||||||
|
|
||||||
for i := 0; i < left.Len(); i++ {
|
|
||||||
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
|
||||||
if value.Kind() == reflect.Pointer {
|
|
||||||
return impl.getMapValue(value.Elem())
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Interface(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
|
||||||
operand, err := impl.evaluateNode(notNode.Operand)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return !IsTruthy(operand), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(compareNode.Left)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(compareNode.Right)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
|
||||||
if leftValue.Kind() != rightValue.Kind() {
|
|
||||||
if !impl.isNumber(leftValue) {
|
|
||||||
leftValue = impl.coerceToNumber(leftValue)
|
|
||||||
}
|
|
||||||
if !impl.isNumber(rightValue) {
|
|
||||||
rightValue = impl.coerceToNumber(rightValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch leftValue.Kind() {
|
|
||||||
case reflect.Bool:
|
|
||||||
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
|
||||||
case reflect.String:
|
|
||||||
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
if rightValue.Kind() == reflect.Float64 {
|
|
||||||
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if rightValue.Kind() == reflect.Int {
|
|
||||||
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
|
||||||
|
|
||||||
case reflect.Invalid:
|
|
||||||
if rightValue.Kind() == reflect.Invalid {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// not possible situation - params are converted to the same type in code above
|
|
||||||
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
|
|
||||||
case reflect.Bool:
|
|
||||||
switch value.Bool() {
|
|
||||||
case true:
|
|
||||||
return reflect.ValueOf(1)
|
|
||||||
case false:
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
if value.String() == "" {
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to parse the string as a number
|
|
||||||
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return reflect.ValueOf(math.NaN())
|
|
||||||
}
|
|
||||||
|
|
||||||
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reflect.ValueOf(math.NaN())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) coerceToString(value reflect.Value) reflect.Value {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return reflect.ValueOf("")
|
|
||||||
|
|
||||||
case reflect.Bool:
|
|
||||||
switch value.Bool() {
|
|
||||||
case true:
|
|
||||||
return reflect.ValueOf("true")
|
|
||||||
case false:
|
|
||||||
return reflect.ValueOf("false")
|
|
||||||
}
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
return value
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
return reflect.ValueOf(fmt.Sprint(value))
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if math.IsInf(value.Float(), 1) {
|
|
||||||
return reflect.ValueOf("Infinity")
|
|
||||||
} else if math.IsInf(value.Float(), -1) {
|
|
||||||
return reflect.ValueOf("-Infinity")
|
|
||||||
}
|
|
||||||
return reflect.ValueOf(fmt.Sprintf("%.15G", value.Float()))
|
|
||||||
|
|
||||||
case reflect.Slice:
|
|
||||||
return reflect.ValueOf("Array")
|
|
||||||
|
|
||||||
case reflect.Map:
|
|
||||||
return reflect.ValueOf("Object")
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
|
||||||
switch kind {
|
|
||||||
case actionlint.CompareOpNodeKindLess:
|
|
||||||
return left < right, nil
|
|
||||||
case actionlint.CompareOpNodeKindLessEq:
|
|
||||||
return left <= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreater:
|
|
||||||
return left > right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreaterEq:
|
|
||||||
return left >= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindEq:
|
|
||||||
return left == right, nil
|
|
||||||
case actionlint.CompareOpNodeKindNotEq:
|
|
||||||
return left != right, nil
|
|
||||||
default:
|
|
||||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
|
||||||
switch kind {
|
|
||||||
case actionlint.CompareOpNodeKindLess:
|
|
||||||
return left < right, nil
|
|
||||||
case actionlint.CompareOpNodeKindLessEq:
|
|
||||||
return left <= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreater:
|
|
||||||
return left > right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreaterEq:
|
|
||||||
return left >= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindEq:
|
|
||||||
return left == right, nil
|
|
||||||
case actionlint.CompareOpNodeKindNotEq:
|
|
||||||
return left != right, nil
|
|
||||||
default:
|
|
||||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsTruthy(input any) bool {
|
|
||||||
value := reflect.ValueOf(input)
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Bool:
|
|
||||||
return value.Bool()
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
return value.String() != ""
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
return value.Int() != 0
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if math.IsNaN(value.Float()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Float() != 0
|
|
||||||
|
|
||||||
case reflect.Map, reflect.Slice:
|
|
||||||
return true
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Int, reflect.Float64:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if value.Float() == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Interface()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(compareNode.Left)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
|
|
||||||
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
|
||||||
return impl.getSafeValue(leftValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(compareNode.Right)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
switch compareNode.Kind {
|
|
||||||
case actionlint.LogicalOpNodeKindAnd:
|
|
||||||
return impl.getSafeValue(rightValue), nil
|
|
||||||
case actionlint.LogicalOpNodeKindOr:
|
|
||||||
return impl.getSafeValue(rightValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
|
||||||
args := make([]reflect.Value, 0)
|
|
||||||
|
|
||||||
for _, arg := range funcCallNode.Args {
|
|
||||||
value, err := impl.evaluateNode(arg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, reflect.ValueOf(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(funcCallNode.Callee) {
|
|
||||||
case "contains":
|
|
||||||
return impl.contains(args[0], args[1])
|
|
||||||
case "startswith":
|
|
||||||
return impl.startsWith(args[0], args[1])
|
|
||||||
case "endswith":
|
|
||||||
return impl.endsWith(args[0], args[1])
|
|
||||||
case "format":
|
|
||||||
return impl.format(args[0], args[1:]...)
|
|
||||||
case "join":
|
|
||||||
if len(args) == 1 {
|
|
||||||
return impl.join(args[0], reflect.ValueOf(","))
|
|
||||||
}
|
|
||||||
return impl.join(args[0], args[1])
|
|
||||||
case "tojson":
|
|
||||||
return impl.toJSON(args[0])
|
|
||||||
case "fromjson":
|
|
||||||
return impl.fromJSON(args[0])
|
|
||||||
case "hashfiles":
|
|
||||||
if impl.env.HashFiles != nil {
|
|
||||||
return impl.env.HashFiles(args)
|
|
||||||
}
|
|
||||||
return impl.hashFiles(args...)
|
|
||||||
case "always":
|
|
||||||
return impl.always()
|
|
||||||
case "success":
|
|
||||||
if impl.config.Context == "job" {
|
|
||||||
return impl.jobSuccess()
|
|
||||||
}
|
|
||||||
if impl.config.Context == "step" {
|
|
||||||
return impl.stepSuccess()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
|
||||||
case "failure":
|
|
||||||
if impl.config.Context == "job" {
|
|
||||||
return impl.jobFailure()
|
|
||||||
}
|
|
||||||
if impl.config.Context == "step" {
|
|
||||||
return impl.stepFailure()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
|
||||||
case "cancelled":
|
|
||||||
return impl.cancelled()
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,635 +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 (
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLiterals(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"true", true, "true"},
|
|
||||||
{"false", false, "false"},
|
|
||||||
{"null", nil, "null"},
|
|
||||||
{"123", 123, "integer"},
|
|
||||||
{"-9.7", -9.7, "float"},
|
|
||||||
{"0xff", 255, "hex"},
|
|
||||||
{"-2.99e-2", -2.99e-2, "exponential"},
|
|
||||||
{"'foo'", "foo", "string"},
|
|
||||||
{"'it''s foo'", "it's foo", "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 TestOperators(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
error string
|
|
||||||
}{
|
|
||||||
{"(false || (false || true))", true, "logical-grouping", ""},
|
|
||||||
{"github.action", "push", "property-dereference", ""},
|
|
||||||
{"github['action']", "push", "property-index", ""},
|
|
||||||
{"github.action[0]", nil, "string-index", ""},
|
|
||||||
{"github.action['0']", nil, "string-index", ""},
|
|
||||||
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
|
||||||
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
|
||||||
// Disabled weird things are happening
|
|
||||||
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
|
||||||
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
|
||||||
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
|
||||||
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
|
||||||
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
|
||||||
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
|
||||||
{"!true", false, "not", ""},
|
|
||||||
{"1 < 2", true, "less-than", ""},
|
|
||||||
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
|
||||||
{"1 > 2", false, "greater-than", ""},
|
|
||||||
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
|
||||||
{`'a' == 'a'`, true, "equal", ""},
|
|
||||||
{`'a' != 'a'`, false, "not-equal", ""},
|
|
||||||
{`true && false`, false, "and", ""},
|
|
||||||
{`true || false`, true, "or", ""},
|
|
||||||
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
|
||||||
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
|
||||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
|
||||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
|
||||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
|
||||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
|
||||||
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
Event: map[string]any{
|
|
||||||
"commits": []any{
|
|
||||||
map[string]any{
|
|
||||||
"author": map[string]any{
|
|
||||||
"username": "someone",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"author": map[string]any{
|
|
||||||
"username": "someone-else",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
if tt.error != "" {
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
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 TestOperatorsCompare(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"!null", true, "not-null"},
|
|
||||||
{"!-10", false, "not-neg-num"},
|
|
||||||
{"!0", true, "not-zero"},
|
|
||||||
{"!3.14", false, "not-pos-float"},
|
|
||||||
{"!''", true, "not-empty-str"},
|
|
||||||
{"!'abc'", false, "not-str"},
|
|
||||||
{"!fromJSON('{}')", false, "not-obj"},
|
|
||||||
{"!fromJSON('[]')", false, "not-arr"},
|
|
||||||
{`null == 0 }}`, true, "null-coercion"},
|
|
||||||
{`true == 1 }}`, true, "boolean-coercion"},
|
|
||||||
{`'' == 0 }}`, true, "string-0-coercion"},
|
|
||||||
{`'3' == 3 }}`, true, "string-3-coercion"},
|
|
||||||
{`0 == null }}`, true, "null-coercion-alt"},
|
|
||||||
{`1 == true }}`, true, "boolean-coercion-alt"},
|
|
||||||
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
|
||||||
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
|
||||||
{`'TEST' == 'test' }}`, true, "string-casing"},
|
|
||||||
{"true > false }}", true, "bool-greater-than"},
|
|
||||||
{"true >= false }}", true, "bool-greater-than-eq"},
|
|
||||||
{"true >= true }}", true, "bool-greater-than-1"},
|
|
||||||
{"true != false }}", true, "bool-not-equal"},
|
|
||||||
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
|
||||||
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
|
||||||
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestOperatorsBooleanEvaluation(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
// true &&
|
|
||||||
{"true && true", true, "true-and"},
|
|
||||||
{"true && false", false, "true-and"},
|
|
||||||
{"true && null", nil, "true-and"},
|
|
||||||
{"true && -10", -10, "true-and"},
|
|
||||||
{"true && 0", 0, "true-and"},
|
|
||||||
{"true && 10", 10, "true-and"},
|
|
||||||
{"true && 3.14", 3.14, "true-and"},
|
|
||||||
{"true && 0.0", 0, "true-and"},
|
|
||||||
{"true && Infinity", math.Inf(1), "true-and"},
|
|
||||||
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
|
||||||
{"true && NaN", math.NaN(), "true-and"},
|
|
||||||
{"true && ''", "", "true-and"},
|
|
||||||
{"true && 'abc'", "abc", "true-and"},
|
|
||||||
// false &&
|
|
||||||
{"false && true", false, "false-and"},
|
|
||||||
{"false && false", false, "false-and"},
|
|
||||||
{"false && null", false, "false-and"},
|
|
||||||
{"false && -10", false, "false-and"},
|
|
||||||
{"false && 0", false, "false-and"},
|
|
||||||
{"false && 10", false, "false-and"},
|
|
||||||
{"false && 3.14", false, "false-and"},
|
|
||||||
{"false && 0.0", false, "false-and"},
|
|
||||||
{"false && Infinity", false, "false-and"},
|
|
||||||
// {"false && -Infinity", false, "false-and"},
|
|
||||||
{"false && NaN", false, "false-and"},
|
|
||||||
{"false && ''", false, "false-and"},
|
|
||||||
{"false && 'abc'", false, "false-and"},
|
|
||||||
// true ||
|
|
||||||
{"true || true", true, "true-or"},
|
|
||||||
{"true || false", true, "true-or"},
|
|
||||||
{"true || null", true, "true-or"},
|
|
||||||
{"true || -10", true, "true-or"},
|
|
||||||
{"true || 0", true, "true-or"},
|
|
||||||
{"true || 10", true, "true-or"},
|
|
||||||
{"true || 3.14", true, "true-or"},
|
|
||||||
{"true || 0.0", true, "true-or"},
|
|
||||||
{"true || Infinity", true, "true-or"},
|
|
||||||
// {"true || -Infinity", true, "true-or"},
|
|
||||||
{"true || NaN", true, "true-or"},
|
|
||||||
{"true || ''", true, "true-or"},
|
|
||||||
{"true || 'abc'", true, "true-or"},
|
|
||||||
// false ||
|
|
||||||
{"false || true", true, "false-or"},
|
|
||||||
{"false || false", false, "false-or"},
|
|
||||||
{"false || null", nil, "false-or"},
|
|
||||||
{"false || -10", -10, "false-or"},
|
|
||||||
{"false || 0", 0, "false-or"},
|
|
||||||
{"false || 10", 10, "false-or"},
|
|
||||||
{"false || 3.14", 3.14, "false-or"},
|
|
||||||
{"false || 0.0", 0, "false-or"},
|
|
||||||
{"false || Infinity", math.Inf(1), "false-or"},
|
|
||||||
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
|
||||||
{"false || NaN", math.NaN(), "false-or"},
|
|
||||||
{"false || ''", "", "false-or"},
|
|
||||||
{"false || 'abc'", "abc", "false-or"},
|
|
||||||
// null &&
|
|
||||||
{"null && true", nil, "null-and"},
|
|
||||||
{"null && false", nil, "null-and"},
|
|
||||||
{"null && null", nil, "null-and"},
|
|
||||||
{"null && -10", nil, "null-and"},
|
|
||||||
{"null && 0", nil, "null-and"},
|
|
||||||
{"null && 10", nil, "null-and"},
|
|
||||||
{"null && 3.14", nil, "null-and"},
|
|
||||||
{"null && 0.0", nil, "null-and"},
|
|
||||||
{"null && Infinity", nil, "null-and"},
|
|
||||||
// {"null && -Infinity", nil, "null-and"},
|
|
||||||
{"null && NaN", nil, "null-and"},
|
|
||||||
{"null && ''", nil, "null-and"},
|
|
||||||
{"null && 'abc'", nil, "null-and"},
|
|
||||||
// null ||
|
|
||||||
{"null || true", true, "null-or"},
|
|
||||||
{"null || false", false, "null-or"},
|
|
||||||
{"null || null", nil, "null-or"},
|
|
||||||
{"null || -10", -10, "null-or"},
|
|
||||||
{"null || 0", 0, "null-or"},
|
|
||||||
{"null || 10", 10, "null-or"},
|
|
||||||
{"null || 3.14", 3.14, "null-or"},
|
|
||||||
{"null || 0.0", 0, "null-or"},
|
|
||||||
{"null || Infinity", math.Inf(1), "null-or"},
|
|
||||||
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
|
||||||
{"null || NaN", math.NaN(), "null-or"},
|
|
||||||
{"null || ''", "", "null-or"},
|
|
||||||
{"null || 'abc'", "abc", "null-or"},
|
|
||||||
// -10 &&
|
|
||||||
{"-10 && true", true, "neg-num-and"},
|
|
||||||
{"-10 && false", false, "neg-num-and"},
|
|
||||||
{"-10 && null", nil, "neg-num-and"},
|
|
||||||
{"-10 && -10", -10, "neg-num-and"},
|
|
||||||
{"-10 && 0", 0, "neg-num-and"},
|
|
||||||
{"-10 && 10", 10, "neg-num-and"},
|
|
||||||
{"-10 && 3.14", 3.14, "neg-num-and"},
|
|
||||||
{"-10 && 0.0", 0, "neg-num-and"},
|
|
||||||
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
|
||||||
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
|
||||||
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
|
||||||
{"-10 && ''", "", "neg-num-and"},
|
|
||||||
{"-10 && 'abc'", "abc", "neg-num-and"},
|
|
||||||
// -10 ||
|
|
||||||
{"-10 || true", -10, "neg-num-or"},
|
|
||||||
{"-10 || false", -10, "neg-num-or"},
|
|
||||||
{"-10 || null", -10, "neg-num-or"},
|
|
||||||
{"-10 || -10", -10, "neg-num-or"},
|
|
||||||
{"-10 || 0", -10, "neg-num-or"},
|
|
||||||
{"-10 || 10", -10, "neg-num-or"},
|
|
||||||
{"-10 || 3.14", -10, "neg-num-or"},
|
|
||||||
{"-10 || 0.0", -10, "neg-num-or"},
|
|
||||||
{"-10 || Infinity", -10, "neg-num-or"},
|
|
||||||
// {"-10 || -Infinity", -10, "neg-num-or"},
|
|
||||||
{"-10 || NaN", -10, "neg-num-or"},
|
|
||||||
{"-10 || ''", -10, "neg-num-or"},
|
|
||||||
{"-10 || 'abc'", -10, "neg-num-or"},
|
|
||||||
// 0 &&
|
|
||||||
{"0 && true", 0, "zero-and"},
|
|
||||||
{"0 && false", 0, "zero-and"},
|
|
||||||
{"0 && null", 0, "zero-and"},
|
|
||||||
{"0 && -10", 0, "zero-and"},
|
|
||||||
{"0 && 0", 0, "zero-and"},
|
|
||||||
{"0 && 10", 0, "zero-and"},
|
|
||||||
{"0 && 3.14", 0, "zero-and"},
|
|
||||||
{"0 && 0.0", 0, "zero-and"},
|
|
||||||
{"0 && Infinity", 0, "zero-and"},
|
|
||||||
// {"0 && -Infinity", 0, "zero-and"},
|
|
||||||
{"0 && NaN", 0, "zero-and"},
|
|
||||||
{"0 && ''", 0, "zero-and"},
|
|
||||||
{"0 && 'abc'", 0, "zero-and"},
|
|
||||||
// 0 ||
|
|
||||||
{"0 || true", true, "zero-or"},
|
|
||||||
{"0 || false", false, "zero-or"},
|
|
||||||
{"0 || null", nil, "zero-or"},
|
|
||||||
{"0 || -10", -10, "zero-or"},
|
|
||||||
{"0 || 0", 0, "zero-or"},
|
|
||||||
{"0 || 10", 10, "zero-or"},
|
|
||||||
{"0 || 3.14", 3.14, "zero-or"},
|
|
||||||
{"0 || 0.0", 0, "zero-or"},
|
|
||||||
{"0 || Infinity", math.Inf(1), "zero-or"},
|
|
||||||
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
|
||||||
{"0 || NaN", math.NaN(), "zero-or"},
|
|
||||||
{"0 || ''", "", "zero-or"},
|
|
||||||
{"0 || 'abc'", "abc", "zero-or"},
|
|
||||||
// 10 &&
|
|
||||||
{"10 && true", true, "pos-num-and"},
|
|
||||||
{"10 && false", false, "pos-num-and"},
|
|
||||||
{"10 && null", nil, "pos-num-and"},
|
|
||||||
{"10 && -10", -10, "pos-num-and"},
|
|
||||||
{"10 && 0", 0, "pos-num-and"},
|
|
||||||
{"10 && 10", 10, "pos-num-and"},
|
|
||||||
{"10 && 3.14", 3.14, "pos-num-and"},
|
|
||||||
{"10 && 0.0", 0, "pos-num-and"},
|
|
||||||
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
|
||||||
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
|
||||||
{"10 && NaN", math.NaN(), "pos-num-and"},
|
|
||||||
{"10 && ''", "", "pos-num-and"},
|
|
||||||
{"10 && 'abc'", "abc", "pos-num-and"},
|
|
||||||
// 10 ||
|
|
||||||
{"10 || true", 10, "pos-num-or"},
|
|
||||||
{"10 || false", 10, "pos-num-or"},
|
|
||||||
{"10 || null", 10, "pos-num-or"},
|
|
||||||
{"10 || -10", 10, "pos-num-or"},
|
|
||||||
{"10 || 0", 10, "pos-num-or"},
|
|
||||||
{"10 || 10", 10, "pos-num-or"},
|
|
||||||
{"10 || 3.14", 10, "pos-num-or"},
|
|
||||||
{"10 || 0.0", 10, "pos-num-or"},
|
|
||||||
{"10 || Infinity", 10, "pos-num-or"},
|
|
||||||
// {"10 || -Infinity", 10, "pos-num-or"},
|
|
||||||
{"10 || NaN", 10, "pos-num-or"},
|
|
||||||
{"10 || ''", 10, "pos-num-or"},
|
|
||||||
{"10 || 'abc'", 10, "pos-num-or"},
|
|
||||||
// 3.14 &&
|
|
||||||
{"3.14 && true", true, "pos-float-and"},
|
|
||||||
{"3.14 && false", false, "pos-float-and"},
|
|
||||||
{"3.14 && null", nil, "pos-float-and"},
|
|
||||||
{"3.14 && -10", -10, "pos-float-and"},
|
|
||||||
{"3.14 && 0", 0, "pos-float-and"},
|
|
||||||
{"3.14 && 10", 10, "pos-float-and"},
|
|
||||||
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
|
||||||
{"3.14 && 0.0", 0, "pos-float-and"},
|
|
||||||
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
|
||||||
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
|
||||||
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
|
||||||
{"3.14 && ''", "", "pos-float-and"},
|
|
||||||
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
|
||||||
// 3.14 ||
|
|
||||||
{"3.14 || true", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || false", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || null", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || -10", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 0", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 10", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
|
||||||
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || NaN", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || ''", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
|
||||||
// Infinity &&
|
|
||||||
{"Infinity && true", true, "pos-inf-and"},
|
|
||||||
{"Infinity && false", false, "pos-inf-and"},
|
|
||||||
{"Infinity && null", nil, "pos-inf-and"},
|
|
||||||
{"Infinity && -10", -10, "pos-inf-and"},
|
|
||||||
{"Infinity && 0", 0, "pos-inf-and"},
|
|
||||||
{"Infinity && 10", 10, "pos-inf-and"},
|
|
||||||
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
|
||||||
{"Infinity && 0.0", 0, "pos-inf-and"},
|
|
||||||
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
|
||||||
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
|
||||||
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
|
||||||
{"Infinity && ''", "", "pos-inf-and"},
|
|
||||||
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
|
||||||
// Infinity ||
|
|
||||||
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
|
||||||
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
|
||||||
// -Infinity &&
|
|
||||||
// {"-Infinity && true", true, "neg-inf-and"},
|
|
||||||
// {"-Infinity && false", false, "neg-inf-and"},
|
|
||||||
// {"-Infinity && null", nil, "neg-inf-and"},
|
|
||||||
// {"-Infinity && -10", -10, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 0", 0, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 10", 10, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
|
||||||
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
|
||||||
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
|
||||||
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
|
||||||
// {"-Infinity && ''", "", "neg-inf-and"},
|
|
||||||
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
|
||||||
// -Infinity ||
|
|
||||||
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// NaN &&
|
|
||||||
{"NaN && true", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && false", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && null", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && -10", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 0", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 10", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 3.14", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 0.0", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && Infinity", math.NaN(), "nan-and"},
|
|
||||||
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && NaN", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && ''", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
|
||||||
// NaN ||
|
|
||||||
{"NaN || true", true, "nan-or"},
|
|
||||||
{"NaN || false", false, "nan-or"},
|
|
||||||
{"NaN || null", nil, "nan-or"},
|
|
||||||
{"NaN || -10", -10, "nan-or"},
|
|
||||||
{"NaN || 0", 0, "nan-or"},
|
|
||||||
{"NaN || 10", 10, "nan-or"},
|
|
||||||
{"NaN || 3.14", 3.14, "nan-or"},
|
|
||||||
{"NaN || 0.0", 0, "nan-or"},
|
|
||||||
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
|
||||||
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
|
||||||
{"NaN || NaN", math.NaN(), "nan-or"},
|
|
||||||
{"NaN || ''", "", "nan-or"},
|
|
||||||
{"NaN || 'abc'", "abc", "nan-or"},
|
|
||||||
// "" &&
|
|
||||||
{"'' && true", "", "empty-str-and"},
|
|
||||||
{"'' && false", "", "empty-str-and"},
|
|
||||||
{"'' && null", "", "empty-str-and"},
|
|
||||||
{"'' && -10", "", "empty-str-and"},
|
|
||||||
{"'' && 0", "", "empty-str-and"},
|
|
||||||
{"'' && 10", "", "empty-str-and"},
|
|
||||||
{"'' && 3.14", "", "empty-str-and"},
|
|
||||||
{"'' && 0.0", "", "empty-str-and"},
|
|
||||||
{"'' && Infinity", "", "empty-str-and"},
|
|
||||||
// {"'' && -Infinity", "", "empty-str-and"},
|
|
||||||
{"'' && NaN", "", "empty-str-and"},
|
|
||||||
{"'' && ''", "", "empty-str-and"},
|
|
||||||
{"'' && 'abc'", "", "empty-str-and"},
|
|
||||||
// "" ||
|
|
||||||
{"'' || true", true, "empty-str-or"},
|
|
||||||
{"'' || false", false, "empty-str-or"},
|
|
||||||
{"'' || null", nil, "empty-str-or"},
|
|
||||||
{"'' || -10", -10, "empty-str-or"},
|
|
||||||
{"'' || 0", 0, "empty-str-or"},
|
|
||||||
{"'' || 10", 10, "empty-str-or"},
|
|
||||||
{"'' || 3.14", 3.14, "empty-str-or"},
|
|
||||||
{"'' || 0.0", 0, "empty-str-or"},
|
|
||||||
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
|
||||||
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
|
||||||
{"'' || NaN", math.NaN(), "empty-str-or"},
|
|
||||||
{"'' || ''", "", "empty-str-or"},
|
|
||||||
{"'' || 'abc'", "abc", "empty-str-or"},
|
|
||||||
// "abc" &&
|
|
||||||
{"'abc' && true", true, "str-and"},
|
|
||||||
{"'abc' && false", false, "str-and"},
|
|
||||||
{"'abc' && null", nil, "str-and"},
|
|
||||||
{"'abc' && -10", -10, "str-and"},
|
|
||||||
{"'abc' && 0", 0, "str-and"},
|
|
||||||
{"'abc' && 10", 10, "str-and"},
|
|
||||||
{"'abc' && 3.14", 3.14, "str-and"},
|
|
||||||
{"'abc' && 0.0", 0, "str-and"},
|
|
||||||
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
|
||||||
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
|
||||||
{"'abc' && NaN", math.NaN(), "str-and"},
|
|
||||||
{"'abc' && ''", "", "str-and"},
|
|
||||||
{"'abc' && 'abc'", "abc", "str-and"},
|
|
||||||
// "abc" ||
|
|
||||||
{"'abc' || true", "abc", "str-or"},
|
|
||||||
{"'abc' || false", "abc", "str-or"},
|
|
||||||
{"'abc' || null", "abc", "str-or"},
|
|
||||||
{"'abc' || -10", "abc", "str-or"},
|
|
||||||
{"'abc' || 0", "abc", "str-or"},
|
|
||||||
{"'abc' || 10", "abc", "str-or"},
|
|
||||||
{"'abc' || 3.14", "abc", "str-or"},
|
|
||||||
{"'abc' || 0.0", "abc", "str-or"},
|
|
||||||
{"'abc' || Infinity", "abc", "str-or"},
|
|
||||||
// {"'abc' || -Infinity", "abc", "str-or"},
|
|
||||||
{"'abc' || NaN", "abc", "str-or"},
|
|
||||||
{"'abc' || ''", "abc", "str-or"},
|
|
||||||
{"'abc' || 'abc'", "abc", "str-or"},
|
|
||||||
// extra tests
|
|
||||||
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
|
||||||
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
|
||||||
number, ok := output.(float64)
|
|
||||||
require.True(t, ok, "want a number, got %T", output)
|
|
||||||
assert.True(t, math.IsNaN(number))
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContexts(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"github.action", "push", "github-context"},
|
|
||||||
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
|
||||||
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
|
||||||
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
|
||||||
{"env.TEST", "value", "env-context"},
|
|
||||||
{"job.status", "success", "job-context"},
|
|
||||||
{"steps.step-id.outputs.name", "value", "steps-context"},
|
|
||||||
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
|
||||||
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
|
||||||
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
|
||||||
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
|
||||||
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
|
||||||
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
|
||||||
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
|
||||||
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
|
||||||
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
|
||||||
// Disabled, since the interpreter is still too broken
|
|
||||||
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
|
||||||
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
|
||||||
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
|
||||||
{"runner.os", "Linux", "runner-context"},
|
|
||||||
{"secrets.name", "value", "secrets-context"},
|
|
||||||
{"vars.name", "value", "vars-context"},
|
|
||||||
{"strategy.fail-fast", true, "strategy-context"},
|
|
||||||
{"matrix.os", "Linux", "matrix-context"},
|
|
||||||
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
|
||||||
{"needs.job-id.result", "success", "needs-context"},
|
|
||||||
{"inputs.name", "value", "inputs-context"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
Env: map[string]string{
|
|
||||||
"TEST": "value",
|
|
||||||
},
|
|
||||||
Job: &model.JobContext{
|
|
||||||
Status: "success",
|
|
||||||
},
|
|
||||||
Steps: map[string]*model.StepResult{
|
|
||||||
"step-id": {
|
|
||||||
Outputs: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"step-id2": {
|
|
||||||
Outcome: model.StepStatusFailure,
|
|
||||||
Conclusion: model.StepStatusSkipped,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Runner: map[string]any{
|
|
||||||
"os": "Linux",
|
|
||||||
"temp": "/tmp",
|
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
|
||||||
},
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
Vars: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
Strategy: map[string]any{
|
|
||||||
"fail-fast": true,
|
|
||||||
},
|
|
||||||
Matrix: map[string]any{
|
|
||||||
"os": "Linux",
|
|
||||||
},
|
|
||||||
Needs: map[string]Needs{
|
|
||||||
"job-id": {
|
|
||||||
Outputs: map[string]string{
|
|
||||||
"output-name": "value",
|
|
||||||
},
|
|
||||||
Result: "success",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Inputs: map[string]any{
|
|
||||||
"name": "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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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]
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package model
|
package ghcontext
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -97,13 +98,13 @@ func TestSetRef(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRef(context.Background(), "main", "/some/dir")
|
SetRef(context.Background(), ghc, "main", "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -116,12 +117,12 @@ func TestSetRef(t *testing.T) {
|
|||||||
return "", errors.New("no default branch")
|
return "", errors.New("no default branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: "no-default-branch",
|
EventName: "no-default-branch",
|
||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRef(context.Background(), "", "/some/dir")
|
SetRef(context.Background(), ghc, "", "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -202,13 +203,13 @@ func TestSetSha(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetSha(context.Background(), "/some/dir")
|
SetSha(context.Background(), ghc, "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ActionRunsUsing is the type of runner for the action
|
|
||||||
type ActionRunsUsing string
|
|
||||||
|
|
||||||
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
|
|
||||||
var using string
|
|
||||||
if err := unmarshal(&using); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force input to lowercase for case insensitive comparison
|
|
||||||
format := ActionRunsUsing(strings.ToLower(using))
|
|
||||||
switch format {
|
|
||||||
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
|
|
||||||
*a = format
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
|
||||||
ActionRunsUsingComposite,
|
|
||||||
ActionRunsUsingDocker,
|
|
||||||
ActionRunsUsingNode12,
|
|
||||||
ActionRunsUsingNode16,
|
|
||||||
ActionRunsUsingNode20,
|
|
||||||
ActionRunsUsingNode24,
|
|
||||||
ActionRunsUsingGo,
|
|
||||||
}, format)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ActionRunsUsingNode12 for running with node12
|
|
||||||
ActionRunsUsingNode12 = "node12"
|
|
||||||
// ActionRunsUsingNode16 for running with node16
|
|
||||||
ActionRunsUsingNode16 = "node16"
|
|
||||||
// ActionRunsUsingNode20 for running with node20
|
|
||||||
ActionRunsUsingNode20 = "node20"
|
|
||||||
// ActionRunsUsingNode24 for running with node24
|
|
||||||
ActionRunsUsingNode24 = "node24"
|
|
||||||
// ActionRunsUsingDocker for running with docker
|
|
||||||
ActionRunsUsingDocker = "docker"
|
|
||||||
// ActionRunsUsingComposite for running composite
|
|
||||||
ActionRunsUsingComposite = "composite"
|
|
||||||
// ActionRunsUsingGo for running with go
|
|
||||||
ActionRunsUsingGo = "go"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsNode() bool {
|
|
||||||
switch a {
|
|
||||||
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsDocker() bool {
|
|
||||||
return a == ActionRunsUsingDocker
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsComposite() bool {
|
|
||||||
return a == ActionRunsUsingComposite
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActionRuns are a field in Action
|
|
||||||
type ActionRuns struct {
|
|
||||||
Using ActionRunsUsing `yaml:"using"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Main string `yaml:"main"`
|
|
||||||
Pre string `yaml:"pre"`
|
|
||||||
PreIf string `yaml:"pre-if"`
|
|
||||||
Post string `yaml:"post"`
|
|
||||||
PostIf string `yaml:"post-if"`
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
PreEntrypoint string `yaml:"pre-entrypoint"`
|
|
||||||
Entrypoint string `yaml:"entrypoint"`
|
|
||||||
PostEntrypoint string `yaml:"post-entrypoint"`
|
|
||||||
Args []string `yaml:"args"`
|
|
||||||
Steps []Step `yaml:"steps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
|
||||||
type Action struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Author string `yaml:"author"`
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Inputs map[string]Input `yaml:"inputs"`
|
|
||||||
Outputs map[string]Output `yaml:"outputs"`
|
|
||||||
Runs ActionRuns `yaml:"runs"`
|
|
||||||
Branding struct {
|
|
||||||
Color string `yaml:"color"`
|
|
||||||
Icon string `yaml:"icon"`
|
|
||||||
} `yaml:"branding"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
|
||||||
type Input struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
|
||||||
type Output struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAction reads an action from a reader
|
|
||||||
func ReadAction(in io.Reader) (*Action, error) {
|
|
||||||
a := new(Action)
|
|
||||||
err := yaml.NewDecoder(in).Decode(a)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// set defaults
|
|
||||||
if a.Runs.PreIf == "" {
|
|
||||||
a.Runs.PreIf = "always()"
|
|
||||||
}
|
|
||||||
if a.Runs.PostIf == "" {
|
|
||||||
a.Runs.PostIf = "always()"
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
name: example
|
|
||||||
runs:
|
|
||||||
using: NoDe24
|
|
||||||
main: dist/index.js
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.Using != ActionRunsUsingNode24 {
|
|
||||||
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
|
||||||
}
|
|
||||||
if action.Runs.PreIf != "always()" {
|
|
||||||
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
|
||||||
}
|
|
||||||
if action.Runs.PostIf != "always()" {
|
|
||||||
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
pre-if: success()
|
|
||||||
post-if: failure()
|
|
||||||
steps:
|
|
||||||
- run: echo hello
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
|
||||||
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
|
||||||
}
|
|
||||||
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
|
||||||
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
|
||||||
_, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: node99
|
|
||||||
`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected unknown runs.using to fail")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "node99") {
|
|
||||||
t.Fatalf("error = %q, want invalid value", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionDockerEntrypoints(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: docker
|
|
||||||
image: Dockerfile
|
|
||||||
pre-entrypoint: pre.sh
|
|
||||||
post-entrypoint: post.sh
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.PreEntrypoint != "pre.sh" {
|
|
||||||
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
|
|
||||||
}
|
|
||||||
if action.Runs.PostEntrypoint != "post.sh" {
|
|
||||||
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GithubContext struct {
|
|
||||||
Event map[string]any `json:"event"`
|
|
||||||
EventPath string `json:"event_path"`
|
|
||||||
Workflow string `json:"workflow"`
|
|
||||||
RunID string `json:"run_id"`
|
|
||||||
RunNumber string `json:"run_number"`
|
|
||||||
Actor string `json:"actor"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
EventName string `json:"event_name"`
|
|
||||||
Sha string `json:"sha"`
|
|
||||||
Ref string `json:"ref"`
|
|
||||||
RefName string `json:"ref_name"`
|
|
||||||
RefType string `json:"ref_type"`
|
|
||||||
HeadRef string `json:"head_ref"`
|
|
||||||
BaseRef string `json:"base_ref"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Workspace string `json:"workspace"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
ActionPath string `json:"action_path"`
|
|
||||||
ActionRef string `json:"action_ref"`
|
|
||||||
ActionRepository string `json:"action_repository"`
|
|
||||||
Job string `json:"job"`
|
|
||||||
JobName string `json:"job_name"`
|
|
||||||
RepositoryOwner string `json:"repository_owner"`
|
|
||||||
RetentionDays string `json:"retention_days"`
|
|
||||||
RunnerPerflog string `json:"runner_perflog"`
|
|
||||||
RunnerTrackingID string `json:"runner_tracking_id"`
|
|
||||||
ServerURL string `json:"server_url"`
|
|
||||||
APIURL string `json:"api_url"`
|
|
||||||
GraphQLURL string `json:"graphql_url"`
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
RunAttempt string `json:"run_attempt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func asString(v any) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
} else if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
|
||||||
var ok bool
|
|
||||||
|
|
||||||
if len(ks) == 0 { // degenerate input
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if rval, ok = m[ks[0]]; !ok {
|
|
||||||
return nil
|
|
||||||
} else if len(ks) == 1 { // we've reached the final key
|
|
||||||
return rval
|
|
||||||
} else if m, ok = rval.(map[string]any); !ok {
|
|
||||||
return nil
|
|
||||||
} else { // 1+ more keys
|
|
||||||
return nestedMapLookup(m, ks[1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
findGitRef = git.FindGitRef
|
|
||||||
findGitRevision = git.FindGitRevision
|
|
||||||
)
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRef(ctx context.Context, 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 = asString(nestedMapLookup(ghc.Event, "deployment", "ref"))
|
|
||||||
case "release":
|
|
||||||
ghc.Ref = "refs/tags/" + asString(nestedMapLookup(ghc.Event, "release", "tag_name"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
ghc.Ref = asString(ghc.Event["ref"])
|
|
||||||
default:
|
|
||||||
defaultBranch := asString(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/" + asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetSha(ctx context.Context, 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 = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Sha = asString(nestedMapLookup(ghc.Event, "deployment", "sha"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
|
||||||
ghc.Sha = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRepositoryAndOwner(ctx context.Context, githubInstance, remoteName, repoPath string) {
|
|
||||||
if ghc.Repository == "" {
|
|
||||||
repo, err := git.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]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRefTypeAndName() {
|
|
||||||
var refType, refName string
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/environment-variables
|
|
||||||
if strings.HasPrefix(ghc.Ref, "refs/tags/") {
|
|
||||||
refType = "tag"
|
|
||||||
refName = ghc.Ref[len("refs/tags/"):]
|
|
||||||
} else if strings.HasPrefix(ghc.Ref, "refs/heads/") {
|
|
||||||
refType = "branch"
|
|
||||||
refName = ghc.Ref[len("refs/heads/"):]
|
|
||||||
} else if strings.HasPrefix(ghc.Ref, "refs/pull/") {
|
|
||||||
refType = ""
|
|
||||||
refName = ghc.Ref[len("refs/pull/"):]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.RefType == "" {
|
|
||||||
ghc.RefType = refType
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.RefName == "" {
|
|
||||||
ghc.RefName = refName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetBaseAndHeadRef() {
|
|
||||||
if ghc.EventName == "pull_request" || ghc.EventName == "pull_request_target" {
|
|
||||||
if ghc.BaseRef == "" {
|
|
||||||
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.HeadRef == "" {
|
|
||||||
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
type JobContext struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Container struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Network string `json:"network"`
|
|
||||||
} `json:"container"`
|
|
||||||
Services map[string]struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"services"`
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WorkflowPlanner contains methods for creating plans
|
|
||||||
type WorkflowPlanner interface {
|
|
||||||
PlanEvent(eventName string) (*Plan, error)
|
|
||||||
PlanJob(jobName string) (*Plan, error)
|
|
||||||
PlanAll() (*Plan, error)
|
|
||||||
GetEvents() []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan contains a list of stages to run in series
|
|
||||||
type Plan struct {
|
|
||||||
Stages []*Stage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage contains a list of runs to execute in parallel
|
|
||||||
type Stage struct {
|
|
||||||
Runs []*Run
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run represents a job from a workflow that needs to be run
|
|
||||||
type Run struct {
|
|
||||||
Workflow *Workflow
|
|
||||||
JobID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Run) String() string {
|
|
||||||
jobName := r.Job().Name
|
|
||||||
if jobName == "" {
|
|
||||||
jobName = r.JobID
|
|
||||||
}
|
|
||||||
return jobName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job returns the job for this Run
|
|
||||||
func (r *Run) Job() *Job {
|
|
||||||
return r.Workflow.GetJob(r.JobID)
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowFiles struct {
|
|
||||||
workflowDirEntry os.DirEntry
|
|
||||||
dirPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
|
||||||
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
|
||||||
path, err := filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var workflows []WorkflowFiles
|
|
||||||
|
|
||||||
if fi.IsDir() {
|
|
||||||
log.Debugf("Loading workflows from '%s'", path)
|
|
||||||
if noWorkflowRecurse {
|
|
||||||
files, err := os.ReadDir(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range files {
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: path,
|
|
||||||
workflowDirEntry: v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debug("Loading workflows recursively")
|
|
||||||
if err := filepath.Walk(path,
|
|
||||||
func(p string, f os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !f.IsDir() {
|
|
||||||
log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: filepath.Dir(p),
|
|
||||||
workflowDirEntry: fs.FileInfoToDirEntry(f),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debugf("Loading workflow '%s'", path)
|
|
||||||
dirname := filepath.Dir(path)
|
|
||||||
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: dirname,
|
|
||||||
workflowDirEntry: fs.FileInfoToDirEntry(fi),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wp := new(workflowPlanner)
|
|
||||||
for _, wf := range workflows {
|
|
||||||
ext := filepath.Ext(wf.workflowDirEntry.Name())
|
|
||||||
if ext == ".yml" || ext == ".yaml" {
|
|
||||||
f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("Reading workflow '%s'", f.Name())
|
|
||||||
workflow, err := ReadWorkflow(f)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
_, err = f.Seek(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workflow.File = wf.workflowDirEntry.Name()
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = wf.workflowDirEntry.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validateJobName(workflow)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wp.workflows = append(wp.workflows, workflow)
|
|
||||||
_ = f.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return wp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
|
|
||||||
func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
|
|
||||||
return &workflowPlanner{
|
|
||||||
workflows: workflows,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
|
|
||||||
wp := new(workflowPlanner)
|
|
||||||
|
|
||||||
log.Debugf("Reading workflow %s", name)
|
|
||||||
workflow, err := ReadWorkflow(f)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
|
|
||||||
}
|
|
||||||
workflow.File = name
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validateJobName(workflow)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wp.workflows = append(wp.workflows, workflow)
|
|
||||||
|
|
||||||
return wp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateJobName(workflow *Workflow) error {
|
|
||||||
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
|
|
||||||
for k := range workflow.Jobs {
|
|
||||||
if ok := jobNameRegex.MatchString(k); !ok {
|
|
||||||
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type workflowPlanner struct {
|
|
||||||
workflows []*Workflow
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanEvent builds a new list of runs to execute in parallel for an event name
|
|
||||||
func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debug("no workflows found by planner")
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
events := w.On()
|
|
||||||
if len(events) == 0 {
|
|
||||||
log.Debugf("no events found for workflow: %s", w.File)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range events {
|
|
||||||
if e == eventName {
|
|
||||||
stages, err := createStages(w, w.GetJobIDs()...)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanJob builds a new run to execute in parallel for a job name
|
|
||||||
func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debugf("no jobs found for workflow: %s", jobName)
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
stages, err := createStages(w, jobName)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanAll builds a new run to execute in parallel all
|
|
||||||
func (wp *workflowPlanner) PlanAll() (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debug("no workflows found by planner")
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
stages, err := createStages(w, w.GetJobIDs()...)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEvents gets all the events in the workflows file
|
|
||||||
func (wp *workflowPlanner) GetEvents() []string {
|
|
||||||
events := make([]string, 0)
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
found := false
|
|
||||||
for _, e := range events {
|
|
||||||
if slices.Contains(w.On(), e) {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
events = append(events, w.On()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort the list based on depth of dependencies
|
|
||||||
slices.Sort(events)
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxRunNameLen determines the max name length of all jobs
|
|
||||||
func (p *Plan) MaxRunNameLen() int {
|
|
||||||
maxRunNameLen := 0
|
|
||||||
for _, stage := range p.Stages {
|
|
||||||
for _, run := range stage.Runs {
|
|
||||||
runNameLen := len(run.String())
|
|
||||||
if runNameLen > maxRunNameLen {
|
|
||||||
maxRunNameLen = runNameLen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxRunNameLen
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJobIDs will get all the job names in the stage
|
|
||||||
func (s *Stage) GetJobIDs() []string {
|
|
||||||
names := make([]string, 0)
|
|
||||||
for _, r := range s.Runs {
|
|
||||||
names = append(names, r.JobID)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge stages with existing stages in plan
|
|
||||||
func (p *Plan) mergeStages(stages []*Stage) {
|
|
||||||
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
|
|
||||||
for i := range newStages {
|
|
||||||
newStages[i] = new(Stage)
|
|
||||||
if i >= len(p.Stages) {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
|
||||||
} else if i >= len(stages) {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
|
||||||
} else {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Stages = newStages
|
|
||||||
}
|
|
||||||
|
|
||||||
func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
|
|
||||||
// first, build a list of all the necessary jobs to run, and their dependencies
|
|
||||||
jobDependencies := make(map[string][]string)
|
|
||||||
for len(jobIDs) > 0 {
|
|
||||||
newJobIDs := make([]string, 0)
|
|
||||||
for _, jID := range jobIDs {
|
|
||||||
// make sure we haven't visited this job yet
|
|
||||||
if _, ok := jobDependencies[jID]; !ok {
|
|
||||||
if job := w.GetJob(jID); job != nil {
|
|
||||||
jobDependencies[jID] = job.Needs()
|
|
||||||
newJobIDs = append(newJobIDs, job.Needs()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobIDs = newJobIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
// next, build an execution graph
|
|
||||||
stages := make([]*Stage, 0)
|
|
||||||
for len(jobDependencies) > 0 {
|
|
||||||
stage := new(Stage)
|
|
||||||
for jID, jDeps := range jobDependencies {
|
|
||||||
// make sure all deps are in the graph already
|
|
||||||
if listInStages(jDeps, stages...) {
|
|
||||||
stage.Runs = append(stage.Runs, &Run{
|
|
||||||
Workflow: w,
|
|
||||||
JobID: jID,
|
|
||||||
})
|
|
||||||
delete(jobDependencies, jID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(stage.Runs) == 0 {
|
|
||||||
return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
|
|
||||||
}
|
|
||||||
stages = append(stages, stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stages) == 0 {
|
|
||||||
return nil, errors.New("Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
|
|
||||||
}
|
|
||||||
|
|
||||||
return stages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// return true iff all strings in srcList exist in at least one of the stages
|
|
||||||
func listInStages(srcList []string, stages ...*Stage) bool {
|
|
||||||
for _, src := range srcList {
|
|
||||||
found := false
|
|
||||||
for _, stage := range stages {
|
|
||||||
for _, search := range stage.GetJobIDs() {
|
|
||||||
if src == search {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkflowPlanTest struct {
|
|
||||||
workflowPath string
|
|
||||||
errorMessage string
|
|
||||||
noWorkflowRecurse bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanner(t *testing.T) {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
tables := []WorkflowPlanTest{
|
|
||||||
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
|
||||||
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
|
||||||
{"invalid-job-name/valid-1.yml", "", false},
|
|
||||||
{"invalid-job-name/valid-2.yml", "", false},
|
|
||||||
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
|
|
||||||
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
|
|
||||||
{"nested", "", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
workdir, err := filepath.Abs("testdata")
|
|
||||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
for _, table := range tables {
|
|
||||||
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
|
|
||||||
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
|
|
||||||
if table.errorMessage == "" {
|
|
||||||
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
|
|
||||||
} else {
|
|
||||||
assert.EqualError(t, err, table.errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkflow(t *testing.T) {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
workflow := Workflow{
|
|
||||||
Jobs: map[string]*Job{
|
|
||||||
"valid_job": {
|
|
||||||
Name: "valid_job",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that an invalid job id returns error
|
|
||||||
result, err := createStages(&workflow, "invalid_job_id")
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Check that an valid job id returns non-error
|
|
||||||
result, err = createStages(&workflow, "valid_job")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
|
||||||
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
|
||||||
name: CI
|
|
||||||
on: [push, pull_request]
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build project
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make build
|
|
||||||
test:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make test
|
|
||||||
`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
|
||||||
|
|
||||||
eventPlan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, eventPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
|
||||||
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
|
||||||
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
|
||||||
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
|
||||||
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
|
||||||
|
|
||||||
jobPlan, err := planner.PlanJob("test")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, jobPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
|
||||||
|
|
||||||
allPlan, err := planner.PlanAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, allPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
|
||||||
first := mustReadWorkflow(t, `
|
|
||||||
name: First
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make build
|
|
||||||
`)
|
|
||||||
second := mustReadWorkflow(t, `
|
|
||||||
name: Second
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make lint
|
|
||||||
test:
|
|
||||||
needs: lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make test
|
|
||||||
`)
|
|
||||||
|
|
||||||
planner := CombineWorkflowPlanner(first, second)
|
|
||||||
plan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, plan.Stages, 2)
|
|
||||||
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
|
||||||
|
|
||||||
empty, err := planner.PlanEvent("schedule")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, empty.Stages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
|
||||||
workflow := mustReadWorkflow(t, `
|
|
||||||
name: Cyclic
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
a:
|
|
||||||
needs: b
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo a
|
|
||||||
b:
|
|
||||||
needs: a
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo b
|
|
||||||
`)
|
|
||||||
planner := CombineWorkflowPlanner(workflow)
|
|
||||||
|
|
||||||
plan, err := planner.PlanJob("missing")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, plan.Stages)
|
|
||||||
assert.Contains(t, err.Error(), "Could not find any stages")
|
|
||||||
|
|
||||||
plan, err = planner.PlanEvent("push")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, plan.Stages)
|
|
||||||
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
|
||||||
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "file is empty")
|
|
||||||
|
|
||||||
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "workflow is not valid")
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
workflow, err := ReadWorkflow(strings.NewReader(content))
|
|
||||||
require.NoError(t, err)
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = "workflow"
|
|
||||||
}
|
|
||||||
return workflow
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type stepStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
StepStatusSuccess stepStatus = iota
|
|
||||||
StepStatusFailure
|
|
||||||
StepStatusSkipped
|
|
||||||
)
|
|
||||||
|
|
||||||
var stepStatusStrings = [...]string{
|
|
||||||
"success",
|
|
||||||
"failure",
|
|
||||||
"skipped",
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stepStatus) MarshalText() ([]byte, error) {
|
|
||||||
return []byte(s.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stepStatus) UnmarshalText(b []byte) error {
|
|
||||||
str := string(b)
|
|
||||||
for i, name := range stepStatusStrings {
|
|
||||||
if name == str {
|
|
||||||
*s = stepStatus(i)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("invalid step status %q", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stepStatus) String() string {
|
|
||||||
if int(s) >= len(stepStatusStrings) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return stepStatusStrings[s]
|
|
||||||
}
|
|
||||||
|
|
||||||
type StepResult struct {
|
|
||||||
Outputs map[string]string `json:"outputs"`
|
|
||||||
Conclusion stepStatus `json:"conclusion"`
|
|
||||||
Outcome stepStatus `json:"outcome"`
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: invalid-job-name-1
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
invalid-JOB-Name-v1.2.3-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: invalid-job-name-2
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
1234invalid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: valid-job-name-1
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: valid-job-name-2
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
___valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
name: Hello World Workflow
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
hello-world:
|
|
||||||
name: Hello World Job
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "Hello World!"
|
|
||||||
Vendored
-50
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
jobs:
|
|
||||||
strategy-all:
|
|
||||||
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
datacenter:
|
|
||||||
- site-c
|
|
||||||
- site-d
|
|
||||||
exclude:
|
|
||||||
- datacenter: site-d
|
|
||||||
node-version: 14.x
|
|
||||||
site: staging
|
|
||||||
include:
|
|
||||||
- php-version: 5.4
|
|
||||||
- datacenter: site-a
|
|
||||||
node-version: 10.x
|
|
||||||
site: prod
|
|
||||||
- datacenter: site-b
|
|
||||||
node-version: 12.x
|
|
||||||
site: dev
|
|
||||||
node-version: [14.x, 16.x]
|
|
||||||
site:
|
|
||||||
- staging
|
|
||||||
max-parallel: 2
|
|
||||||
strategy-no-matrix:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
max-parallel: 2
|
|
||||||
strategy-only-fail-fast:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
strategy-only-max-parallel:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
max-parallel: 2
|
|
||||||
'on':
|
|
||||||
push: null
|
|
||||||
@@ -1,910 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workflow is the structure of the files in .github/workflows
|
|
||||||
type Workflow struct {
|
|
||||||
File string
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
RawOn yaml.Node `yaml:"on"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Jobs map[string]*Job `yaml:"jobs"`
|
|
||||||
Defaults Defaults `yaml:"defaults"`
|
|
||||||
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
|
||||||
RawPermissions yaml.Node `yaml:"permissions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// On events for the workflow
|
|
||||||
func (w *Workflow) On() []string {
|
|
||||||
switch w.RawOn.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val map[string]any
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
var keys []string
|
|
||||||
for k := range val {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) OnEvent(event string) any {
|
|
||||||
if w.RawOn.Kind == yaml.MappingNode {
|
|
||||||
var val map[string]any
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val[event]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) OnSchedule() []string {
|
|
||||||
schedules := w.OnEvent("schedule")
|
|
||||||
if schedules == nil {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch val := schedules.(type) {
|
|
||||||
case []any:
|
|
||||||
allSchedules := []string{}
|
|
||||||
for _, v := range val {
|
|
||||||
entry, ok := v.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cron, ok := entry["cron"].(string); ok {
|
|
||||||
allSchedules = append(allSchedules, cron)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allSchedules
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowDispatchInput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
Type string `yaml:"type"`
|
|
||||||
Options []string `yaml:"options"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowDispatch struct {
|
|
||||||
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
|
|
||||||
switch w.RawOn.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if val == "workflow_dispatch" {
|
|
||||||
return &WorkflowDispatch{}
|
|
||||||
}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if slices.Contains(val, "workflow_dispatch") {
|
|
||||||
return &WorkflowDispatch{}
|
|
||||||
}
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val map[string]yaml.Node
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, found := val["workflow_dispatch"]
|
|
||||||
var workflowDispatch WorkflowDispatch
|
|
||||||
if found && decodeNode(n, &workflowDispatch) {
|
|
||||||
return &workflowDispatch
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallInput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
Type string `yaml:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallOutput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCall struct {
|
|
||||||
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
|
|
||||||
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallResult struct {
|
|
||||||
Outputs map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
|
||||||
if w.RawOn.Kind != yaml.MappingNode {
|
|
||||||
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var val map[string]yaml.Node
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var config WorkflowCall
|
|
||||||
node := val["workflow_call"]
|
|
||||||
if !decodeNode(node, &config) {
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job is the structure of one job in a workflow
|
|
||||||
type Job struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
RawNeeds yaml.Node `yaml:"needs"`
|
|
||||||
RawRunsOn yaml.Node `yaml:"runs-on"`
|
|
||||||
Env yaml.Node `yaml:"env"`
|
|
||||||
If yaml.Node `yaml:"if"`
|
|
||||||
Steps []*Step `yaml:"steps"`
|
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
|
||||||
RawContinueOnError string `yaml:"continue-on-error"`
|
|
||||||
Services map[string]*ContainerSpec `yaml:"services"`
|
|
||||||
Strategy *Strategy `yaml:"strategy"`
|
|
||||||
RawContainer yaml.Node `yaml:"container"`
|
|
||||||
Defaults Defaults `yaml:"defaults"`
|
|
||||||
Outputs map[string]string `yaml:"outputs"`
|
|
||||||
Uses string `yaml:"uses"`
|
|
||||||
With map[string]any `yaml:"with"`
|
|
||||||
RawSecrets yaml.Node `yaml:"secrets"`
|
|
||||||
RawPermissions yaml.Node `yaml:"permissions"`
|
|
||||||
Result string
|
|
||||||
// Runtime fields set during execution (not from YAML):
|
|
||||||
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
|
|
||||||
hasFirmFailure bool // true once any combination failed without continue-on-error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetContinueOnError records whether this combination's failure should not fail the workflow.
|
|
||||||
// Must be called under the job lock. Safe across parallel matrix combinations.
|
|
||||||
func (j *Job) SetContinueOnError(continueOnErr bool) {
|
|
||||||
if continueOnErr {
|
|
||||||
if !j.hasFirmFailure {
|
|
||||||
j.ContinueOnError = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
j.hasFirmFailure = true
|
|
||||||
j.ContinueOnError = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeedsResult returns the job result as seen by dependent jobs through the
|
|
||||||
// `needs` context. A job that failed but was tolerated via continue-on-error
|
|
||||||
// reports "success" to its dependents, matching GitHub: such a failure must not
|
|
||||||
// block jobs gated on the default `if: success()`, even though the overall
|
|
||||||
// workflow run is still marked as failed.
|
|
||||||
func (j *Job) NeedsResult() string {
|
|
||||||
if j.Result == "failure" && j.ContinueOnError {
|
|
||||||
return "success"
|
|
||||||
}
|
|
||||||
return j.Result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy for the job
|
|
||||||
type Strategy struct {
|
|
||||||
FailFast bool
|
|
||||||
MaxParallel int
|
|
||||||
FailFastString string `yaml:"fail-fast"`
|
|
||||||
MaxParallelString string `yaml:"max-parallel"`
|
|
||||||
RawMatrix yaml.Node `yaml:"matrix"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default settings that will apply to all steps in the job or workflow
|
|
||||||
type Defaults struct {
|
|
||||||
Run RunDefaults `yaml:"run"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults for all run steps in the job or workflow
|
|
||||||
type RunDefaults struct {
|
|
||||||
Shell string `yaml:"shell"`
|
|
||||||
WorkingDirectory string `yaml:"working-directory"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMaxParallel sets default and returns value for `max-parallel`
|
|
||||||
func (s Strategy) GetMaxParallel() int {
|
|
||||||
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
|
|
||||||
// So I take the liberty to hardcode default limit to 4 and this is because:
|
|
||||||
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
|
|
||||||
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
|
|
||||||
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
|
|
||||||
maxParallel := 4
|
|
||||||
if s.MaxParallelString != "" {
|
|
||||||
var err error
|
|
||||||
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
|
|
||||||
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFailFast sets default and returns value for `fail-fast`
|
|
||||||
func (s Strategy) GetFailFast() bool {
|
|
||||||
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
|
|
||||||
failFast := true
|
|
||||||
log.Debug(s.FailFastString)
|
|
||||||
if s.FailFastString != "" {
|
|
||||||
var err error
|
|
||||||
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
|
|
||||||
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failFast
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) InheritSecrets() bool {
|
|
||||||
if j.RawSecrets.Kind != yaml.ScalarNode {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var val string
|
|
||||||
if !decodeNode(j.RawSecrets, &val) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return val == "inherit"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) Secrets() map[string]string {
|
|
||||||
if j.RawSecrets.Kind != yaml.MappingNode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var val map[string]string
|
|
||||||
if !decodeNode(j.RawSecrets, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container details for the job
|
|
||||||
func (j *Job) Container() *ContainerSpec {
|
|
||||||
var val *ContainerSpec
|
|
||||||
switch j.RawContainer.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
val = new(ContainerSpec)
|
|
||||||
if !decodeNode(j.RawContainer, &val.Image) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case yaml.MappingNode:
|
|
||||||
val = new(ContainerSpec)
|
|
||||||
if !decodeNode(j.RawContainer, val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needs list for Job
|
|
||||||
func (j *Job) Needs() []string {
|
|
||||||
switch j.RawNeeds.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(j.RawNeeds, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(j.RawNeeds, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunsOn list for Job
|
|
||||||
func (j *Job) RunsOn() []string {
|
|
||||||
return RunsOnFromNode(j.RawRunsOn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
|
||||||
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
|
||||||
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
|
||||||
switch rawRunsOn.Kind {
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val struct {
|
|
||||||
Group string
|
|
||||||
Labels yaml.Node
|
|
||||||
}
|
|
||||||
|
|
||||||
if !decodeNode(rawRunsOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
labels := nodeAsStringSlice(val.Labels)
|
|
||||||
|
|
||||||
if val.Group != "" {
|
|
||||||
labels = append(labels, val.Group)
|
|
||||||
}
|
|
||||||
|
|
||||||
return labels
|
|
||||||
default:
|
|
||||||
return nodeAsStringSlice(rawRunsOn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeAsStringSlice(node yaml.Node) []string {
|
|
||||||
switch node.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(node, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(node, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func environment(yml yaml.Node) map[string]string {
|
|
||||||
env := make(map[string]string)
|
|
||||||
if yml.Kind == yaml.MappingNode {
|
|
||||||
if !decodeNode(yml, &env) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment returns string-based key=value map for a job
|
|
||||||
func (j *Job) Environment() map[string]string {
|
|
||||||
return environment(j.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeMatrixValue converts a matrix value to []interface{}.
|
|
||||||
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
|
|
||||||
// Unevaluated template expressions are wrapped as a fallback — proper resolution
|
|
||||||
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
|
|
||||||
func normalizeMatrixValue(key string, val any) ([]any, error) {
|
|
||||||
switch t := val.(type) {
|
|
||||||
case []any:
|
|
||||||
// Already an array - use as-is
|
|
||||||
return t, nil
|
|
||||||
case string, int, float64, bool, nil:
|
|
||||||
// Valid scalar types that can appear in YAML
|
|
||||||
// These can be unevaluated template expressions (strings) or literal values
|
|
||||||
return []any{t}, nil
|
|
||||||
case map[string]any:
|
|
||||||
// Nested map indicates misconfiguration - likely user error
|
|
||||||
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
|
|
||||||
default:
|
|
||||||
// Unknown types might indicate parsing issues
|
|
||||||
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
|
|
||||||
return []any{t}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
|
|
||||||
// Scalar values are wrapped into single-element arrays automatically.
|
|
||||||
// Template expressions are resolved by EvaluateYamlNode before this method is
|
|
||||||
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
|
||||||
func (j *Job) Matrix() (map[string][]any, error) {
|
|
||||||
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
|
||||||
return map[string][]any{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode to flexible map first so that scalar values don't cause a type error.
|
|
||||||
var flexVal map[string]any
|
|
||||||
err := j.Strategy.RawMatrix.Decode(&flexVal)
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to the strict array-only format for backward compatibility.
|
|
||||||
var val map[string][]any
|
|
||||||
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
|
||||||
return map[string][]any{}, nil
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert flexible format to expected format with validation
|
|
||||||
val := make(map[string][]any)
|
|
||||||
for k, v := range flexVal {
|
|
||||||
normalized, err := normalizeMatrixValue(k, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
val[k] = normalized
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMatrixes returns the matrix cross product
|
|
||||||
// It skips includes and hard fails excludes for non-existing keys
|
|
||||||
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
|
||||||
matrixes := make([]map[string]any, 0)
|
|
||||||
if j.Strategy != nil {
|
|
||||||
// Always set these values, even if there's an error later
|
|
||||||
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
|
||||||
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
|
||||||
|
|
||||||
m, err := j.Matrix()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(m) > 0 {
|
|
||||||
includes := make([]map[string]any, 0)
|
|
||||||
extraIncludes := make([]map[string]any, 0)
|
|
||||||
addInclude := func(raw any) error {
|
|
||||||
include, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
|
|
||||||
}
|
|
||||||
for k := range include {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
includes = append(includes, include)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extraIncludes = append(extraIncludes, include)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, v := range m["include"] {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case []any:
|
|
||||||
for _, i := range t {
|
|
||||||
if err := addInclude(i); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case any:
|
|
||||||
if err := addInclude(t); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m, "include")
|
|
||||||
|
|
||||||
excludes := make([]map[string]any, 0)
|
|
||||||
for _, e := range m["exclude"] {
|
|
||||||
exclude, ok := e.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
|
|
||||||
}
|
|
||||||
for k := range exclude {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
excludes = append(excludes, exclude)
|
|
||||||
} else {
|
|
||||||
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
|
||||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m, "exclude")
|
|
||||||
|
|
||||||
matrixProduct := common.CartesianProduct(m)
|
|
||||||
MATRIX:
|
|
||||||
for _, matrix := range matrixProduct {
|
|
||||||
for _, exclude := range excludes {
|
|
||||||
if commonKeysMatch(matrix, exclude) {
|
|
||||||
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
|
|
||||||
continue MATRIX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
matrixes = append(matrixes, matrix)
|
|
||||||
}
|
|
||||||
for _, include := range includes {
|
|
||||||
matched := false
|
|
||||||
for _, matrix := range matrixes {
|
|
||||||
if commonKeysMatch2(matrix, include, m) {
|
|
||||||
matched = true
|
|
||||||
log.Debugf("Adding include values '%v' to existing entry", include)
|
|
||||||
maps.Copy(matrix, include)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
extraIncludes = append(extraIncludes, include)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, include := range extraIncludes {
|
|
||||||
log.Debugf("Adding include '%v'", include)
|
|
||||||
matrixes = append(matrixes, include)
|
|
||||||
}
|
|
||||||
if len(matrixes) == 0 {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
|
|
||||||
}
|
|
||||||
return matrixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func commonKeysMatch(a, b map[string]any) bool {
|
|
||||||
for aKey, aVal := range a {
|
|
||||||
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
|
|
||||||
for aKey, aVal := range a {
|
|
||||||
_, useKey := m[aKey]
|
|
||||||
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// JobType describes what type of job we are about to run
|
|
||||||
type JobType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// JobTypeDefault is all jobs that have a `run` attribute
|
|
||||||
JobTypeDefault JobType = iota
|
|
||||||
|
|
||||||
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
|
|
||||||
JobTypeReusableWorkflowLocal
|
|
||||||
|
|
||||||
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
|
|
||||||
JobTypeReusableWorkflowRemote
|
|
||||||
|
|
||||||
// JobTypeInvalid represents a job which is not configured correctly
|
|
||||||
JobTypeInvalid
|
|
||||||
)
|
|
||||||
|
|
||||||
func (j JobType) String() string {
|
|
||||||
switch j {
|
|
||||||
case JobTypeDefault:
|
|
||||||
return "default"
|
|
||||||
case JobTypeReusableWorkflowLocal:
|
|
||||||
return "local-reusable-workflow"
|
|
||||||
case JobTypeReusableWorkflowRemote:
|
|
||||||
return "remote-reusable-workflow"
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type returns the type of the job
|
|
||||||
func (j *Job) Type() (JobType, error) {
|
|
||||||
isReusable := j.Uses != ""
|
|
||||||
|
|
||||||
if isReusable {
|
|
||||||
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
|
|
||||||
|
|
||||||
if isYaml {
|
|
||||||
isLocalPath := strings.HasPrefix(j.Uses, "./")
|
|
||||||
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
|
|
||||||
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
|
|
||||||
|
|
||||||
if isLocalPath {
|
|
||||||
return JobTypeReusableWorkflowLocal, nil
|
|
||||||
} else if isRemotePath && hasVersion {
|
|
||||||
return JobTypeReusableWorkflowRemote, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobTypeDefault, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainerSpec is the specification of the container to use for the job
|
|
||||||
type ContainerSpec struct {
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Ports []string `yaml:"ports"`
|
|
||||||
Volumes []string `yaml:"volumes"`
|
|
||||||
Options string `yaml:"options"`
|
|
||||||
Credentials map[string]string `yaml:"credentials"`
|
|
||||||
Entrypoint string
|
|
||||||
Args string
|
|
||||||
Name string
|
|
||||||
Reuse bool
|
|
||||||
|
|
||||||
// Gitea specific
|
|
||||||
Cmd []string `yaml:"cmd"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step is the structure of one step in a job
|
|
||||||
type Step struct {
|
|
||||||
Number int `yaml:"-"`
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
If yaml.Node `yaml:"if"`
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Uses string `yaml:"uses"`
|
|
||||||
Run string `yaml:"run"`
|
|
||||||
WorkingDirectory string `yaml:"working-directory"`
|
|
||||||
Shell string `yaml:"shell"`
|
|
||||||
Env yaml.Node `yaml:"env"`
|
|
||||||
With map[string]string `yaml:"with"`
|
|
||||||
RawContinueOnError string `yaml:"continue-on-error"`
|
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
|
||||||
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
|
||||||
// yaml.Nodes in place, so each job must own its copy.
|
|
||||||
func (s *Step) Clone() *Step {
|
|
||||||
clone := *s
|
|
||||||
clone.If = CloneYamlNode(s.If)
|
|
||||||
clone.Env = CloneYamlNode(s.Env)
|
|
||||||
clone.With = maps.Clone(s.With)
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
|
||||||
// mutating a node shared across parallel jobs.
|
|
||||||
func CloneYamlNode(n yaml.Node) yaml.Node {
|
|
||||||
clone := n
|
|
||||||
if n.Content != nil {
|
|
||||||
clone.Content = make([]*yaml.Node, len(n.Content))
|
|
||||||
for i, child := range n.Content {
|
|
||||||
if child != nil {
|
|
||||||
childClone := CloneYamlNode(*child)
|
|
||||||
clone.Content[i] = &childClone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// String gets the name of step
|
|
||||||
func (s *Step) String() string {
|
|
||||||
if s.Name != "" {
|
|
||||||
return s.Name
|
|
||||||
} else if s.Uses != "" {
|
|
||||||
return s.Uses
|
|
||||||
} else if s.Run != "" {
|
|
||||||
return s.Run
|
|
||||||
}
|
|
||||||
return s.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment returns string-based key=value map for a step
|
|
||||||
func (s *Step) Environment() map[string]string {
|
|
||||||
return environment(s.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEnv gets the env for a step
|
|
||||||
func (s *Step) GetEnv() map[string]string {
|
|
||||||
env := s.Environment()
|
|
||||||
|
|
||||||
for k, v := range s.With {
|
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
|
|
||||||
envKey = "INPUT_" + strings.ToUpper(envKey)
|
|
||||||
env[envKey] = v
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShellCommand returns the command for the shell
|
|
||||||
func (s *Step) ShellCommand() string {
|
|
||||||
var shellCommand string
|
|
||||||
|
|
||||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
|
|
||||||
switch s.Shell {
|
|
||||||
case "", "bash":
|
|
||||||
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
|
|
||||||
case "pwsh":
|
|
||||||
shellCommand = "pwsh -command . '{0}'"
|
|
||||||
case "python":
|
|
||||||
shellCommand = "python {0}"
|
|
||||||
case "sh":
|
|
||||||
shellCommand = "sh -e {0}"
|
|
||||||
case "cmd":
|
|
||||||
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
|
|
||||||
case "powershell":
|
|
||||||
shellCommand = "powershell -command . '{0}'"
|
|
||||||
default:
|
|
||||||
shellCommand = s.Shell
|
|
||||||
}
|
|
||||||
return shellCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
// StepType describes what type of step we are about to run
|
|
||||||
type StepType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// StepTypeRun is all steps that have a `run` attribute
|
|
||||||
StepTypeRun StepType = iota
|
|
||||||
|
|
||||||
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
|
|
||||||
StepTypeUsesDockerURL
|
|
||||||
|
|
||||||
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
|
|
||||||
StepTypeUsesActionLocal
|
|
||||||
|
|
||||||
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
|
|
||||||
StepTypeUsesActionRemote
|
|
||||||
|
|
||||||
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
|
|
||||||
StepTypeReusableWorkflowLocal
|
|
||||||
|
|
||||||
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
|
|
||||||
StepTypeReusableWorkflowRemote
|
|
||||||
|
|
||||||
// StepTypeInvalid is for steps that have invalid step action
|
|
||||||
StepTypeInvalid
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s StepType) String() string {
|
|
||||||
switch s {
|
|
||||||
case StepTypeInvalid:
|
|
||||||
return "invalid"
|
|
||||||
case StepTypeRun:
|
|
||||||
return "run"
|
|
||||||
case StepTypeUsesActionLocal:
|
|
||||||
return "local-action"
|
|
||||||
case StepTypeUsesActionRemote:
|
|
||||||
return "remote-action"
|
|
||||||
case StepTypeUsesDockerURL:
|
|
||||||
return "docker"
|
|
||||||
case StepTypeReusableWorkflowLocal:
|
|
||||||
return "local-reusable-workflow"
|
|
||||||
case StepTypeReusableWorkflowRemote:
|
|
||||||
return "remote-reusable-workflow"
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type returns the type of the step
|
|
||||||
func (s *Step) Type() StepType {
|
|
||||||
if s.Run == "" && s.Uses == "" {
|
|
||||||
return StepTypeInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.Run != "" {
|
|
||||||
if s.Uses != "" {
|
|
||||||
return StepTypeInvalid
|
|
||||||
}
|
|
||||||
return StepTypeRun
|
|
||||||
} else if strings.HasPrefix(s.Uses, "docker://") {
|
|
||||||
return StepTypeUsesDockerURL
|
|
||||||
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
|
|
||||||
return StepTypeReusableWorkflowLocal
|
|
||||||
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
|
|
||||||
return StepTypeReusableWorkflowRemote
|
|
||||||
} else if strings.HasPrefix(s.Uses, "./") {
|
|
||||||
return StepTypeUsesActionLocal
|
|
||||||
}
|
|
||||||
return StepTypeUsesActionRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsesHash returns a hash of the uses string.
|
|
||||||
// For Gitea.
|
|
||||||
func (s *Step) UsesHash() string {
|
|
||||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
|
||||||
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
|
||||||
w := new(Workflow)
|
|
||||||
err := yaml.NewDecoder(in).Decode(w)
|
|
||||||
return w, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJob will get a job by name in the workflow
|
|
||||||
func (w *Workflow) GetJob(jobID string) *Job {
|
|
||||||
for id, j := range w.Jobs {
|
|
||||||
if jobID == id {
|
|
||||||
if j.Name == "" {
|
|
||||||
j.Name = id
|
|
||||||
}
|
|
||||||
if j.If.Value == "" {
|
|
||||||
j.If.Value = "success()"
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJobIDs will get all the job names in the workflow
|
|
||||||
func (w *Workflow) GetJobIDs() []string {
|
|
||||||
ids := make([]string, 0)
|
|
||||||
for id := range w.Jobs {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
|
||||||
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeNode(node yaml.Node, out any) bool {
|
|
||||||
if err := node.Decode(out); err != nil {
|
|
||||||
if OnDecodeNodeError != nil {
|
|
||||||
OnDecodeNodeError(node, out, err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
|
||||||
type RawConcurrency struct {
|
|
||||||
Group string `yaml:"group,omitempty"`
|
|
||||||
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
|
||||||
RawExpression string `yaml:"-,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type objectConcurrency RawConcurrency
|
|
||||||
|
|
||||||
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
|
|
||||||
if err := n.Decode(&r.RawExpression); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return n.Decode((*objectConcurrency)(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RawConcurrency) MarshalYAML() (any, error) {
|
|
||||||
if r.RawExpression != "" {
|
|
||||||
return r.RawExpression, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return (*objectConcurrency)(r), nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
|||||||
|
|
||||||
if sar, ok := step.(*stepActionRemote); ok {
|
if sar, ok := step.(*stepActionRemote); ok {
|
||||||
actionDir = sar.actionDir()
|
actionDir = sar.actionDir()
|
||||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
actionPath = sar.remoteAction.Path
|
||||||
} else {
|
} else {
|
||||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||||
@@ -186,10 +187,10 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("%v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
logger.Errorf("%v", ctx.Err())
|
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||||
common.SetJobError(ctx, ctx.Err())
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -248,10 +249,10 @@ func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
err := runStep(ctx)
|
err := runStep(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("%v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
logger.Errorf("%v", ctx.Err())
|
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||||
common.SetJobError(ctx, ctx.Err())
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
+62
-15
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,17 +46,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if resumeCommand != "" && command != resumeCommand {
|
if resumeCommand != "" {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
// The code in the switch statement is the same.
|
|
||||||
// Return true (not false) so the line still reaches the raw_output
|
// Return true (not false) so the line still reaches the raw_output
|
||||||
// log handler; otherwise everything between ::stop-commands:: and
|
// log handler; otherwise everything between ::stop-commands:: and
|
||||||
// its end token is silently dropped from the step log.
|
// its end token is silently dropped from the step log.
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
|
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||||
|
// and a token naming a real command would otherwise never resume.
|
||||||
|
if command == resumeCommand {
|
||||||
|
resumeCommand = ""
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
arg = UnescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs)
|
||||||
|
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
rc.setEnv(ctx, kvPairs, arg)
|
rc.setEnv(ctx, kvPairs, arg)
|
||||||
@@ -63,27 +71,20 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
rc.setOutput(ctx, kvPairs, arg)
|
rc.setOutput(ctx, kvPairs, arg)
|
||||||
case "add-path":
|
case "add-path":
|
||||||
rc.addPath(ctx, arg)
|
rc.addPath(ctx, arg)
|
||||||
case "debug":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "warning":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "error":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "add-mask":
|
case "add-mask":
|
||||||
rc.AddMask(arg)
|
rc.AddMask(arg)
|
||||||
logger.Infof("%s", "***")
|
logger.Infof("%s", "***")
|
||||||
|
// The raw line is still forwarded, carrying the secret: that is how the reporter
|
||||||
|
// learns the mask, and it drops the row rather than writing it out.
|
||||||
case "stop-commands":
|
case "stop-commands":
|
||||||
resumeCommand = arg
|
resumeCommand = arg
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
case resumeCommand:
|
|
||||||
resumeCommand = ""
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
case "save-state":
|
case "save-state":
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
rc.saveState(ctx, kvPairs, arg)
|
rc.saveState(ctx, kvPairs, arg)
|
||||||
case "add-matcher":
|
|
||||||
logger.Infof("%s", line)
|
|
||||||
default:
|
default:
|
||||||
|
// ::debug::, ::error::, ::warning::, ::add-matcher:: and anything unrecognised are
|
||||||
|
// passed through for the reporter and Gitea's web UI to render.
|
||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +93,52 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS"
|
||||||
|
|
||||||
|
// refuseUnsecureCommand reports whether a deprecated ::set-env:: or ::add-path:: command must
|
||||||
|
// not run, recording the error that fails the step. GitHub disabled both because a step that
|
||||||
|
// echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step.
|
||||||
|
func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool {
|
||||||
|
if rc.allowUnsecureCommandsOptIn() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The step executor logs the failure itself, so keep this line's wording distinct.
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
||||||
|
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
||||||
|
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||||
|
command)))
|
||||||
|
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
if rc.unsecureCommandErr == nil {
|
||||||
|
rc.unsecureCommandErr = fmt.Errorf("the `%s` workflow command is disabled", command)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated
|
||||||
|
// commands, from any env scope, as it can on GitHub.
|
||||||
|
func (rc *RunContext) allowUnsecureCommandsOptIn() bool {
|
||||||
|
return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) ||
|
||||||
|
isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) ||
|
||||||
|
isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar])
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing.
|
||||||
|
func isTruthyEnv(v string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeUnsecureCommandError returns and clears the error left by a refused command.
|
||||||
|
func (rc *RunContext) takeUnsecureCommandError() error {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
err := rc.unsecureCommandErr
|
||||||
|
rc.unsecureCommandErr = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
name := kvPairs["name"]
|
name := kvPairs["name"]
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
@@ -161,9 +208,9 @@ var (
|
|||||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||||
)
|
)
|
||||||
|
|
||||||
// escapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||||
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
||||||
func escapeCommandData(arg string) string {
|
func EscapeCommandData(arg string) string {
|
||||||
return commandDataEscaper.Replace(arg)
|
return commandDataEscaper.Replace(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,22 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
||||||
|
func unsecureRC() *RunContext {
|
||||||
|
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
@@ -31,7 +37,7 @@ func TestSetEnv(t *testing.T) {
|
|||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
// Stop command processing until the matching end token is seen.
|
// Stop command processing until the matching end token is seen.
|
||||||
@@ -84,7 +90,7 @@ func TestSetOutput(t *testing.T) {
|
|||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::add-path::/zoo\n")
|
handler("::add-path::/zoo\n")
|
||||||
@@ -99,7 +105,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
|
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
@@ -119,10 +125,26 @@ func TestStopCommands(t *testing.T) {
|
|||||||
a.Contains(messages, "::set-env name=x::abcd\n")
|
a.Contains(messages, "::set-env name=x::abcd\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The end token is arbitrary, so one that happens to name a real command must still resume
|
||||||
|
// rather than being swallowed by that command's case.
|
||||||
|
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||||
|
a := assert.New(t)
|
||||||
|
rc := unsecureRC()
|
||||||
|
handler := rc.commandHandler(context.Background())
|
||||||
|
|
||||||
|
handler("::stop-commands::add-mask\n")
|
||||||
|
handler("::set-env name=x::suppressed\n")
|
||||||
|
a.NotContains(rc.Env, "x")
|
||||||
|
|
||||||
|
handler("::add-mask::\n")
|
||||||
|
handler("::set-env name=x::resumed\n")
|
||||||
|
a.Equal("resumed", rc.Env["x"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddpathADO(t *testing.T) {
|
func TestAddpathADO(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rc := new(RunContext)
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("##[add-path]/zoo\n")
|
handler("##[add-path]/zoo\n")
|
||||||
@@ -218,6 +240,44 @@ func TestSaveState(t *testing.T) {
|
|||||||
func TestEscapeCommandData(t *testing.T) {
|
func TestEscapeCommandData(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
|
|
||||||
a.Equal("a%25b%0Dc%0Ad%250A", escapeCommandData("a%b\rc\nd%0A"))
|
a.Equal("a%25b%0Dc%0Ad%250A", EscapeCommandData("a%b\rc\nd%0A"))
|
||||||
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
|
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnsecureCommands(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jobEnv map[string]string
|
||||||
|
stepEnv map[string]string
|
||||||
|
optedIn bool
|
||||||
|
}{
|
||||||
|
{name: "refused with no opt-in"},
|
||||||
|
// GitHub reads the opt-in with bool.TryParse, so "1" is not one.
|
||||||
|
{name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}},
|
||||||
|
{name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true},
|
||||||
|
{name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
a := assert.New(t)
|
||||||
|
rc := &RunContext{Env: tt.jobEnv}
|
||||||
|
rc.setCurrentStepEnv(tt.stepEnv)
|
||||||
|
handler := rc.commandHandler(context.Background())
|
||||||
|
|
||||||
|
handler("::set-env name=x::valz\n")
|
||||||
|
handler("::add-path::/opt/bin\n")
|
||||||
|
|
||||||
|
if !tt.optedIn {
|
||||||
|
a.Empty(rc.Env["x"])
|
||||||
|
a.Empty(rc.ExtraPath)
|
||||||
|
// The refusal fails the step that produced it, once.
|
||||||
|
require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env")
|
||||||
|
a.NoError(rc.takeUnsecureCommandError())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.Equal("valz", rc.Env["x"])
|
||||||
|
a.Equal([]string{"/opt/bin"}, rc.ExtraPath)
|
||||||
|
a.NoError(rc.takeUnsecureCommandError())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,3 +78,14 @@ func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string
|
|||||||
}
|
}
|
||||||
return args.Get(0).(io.ReadCloser), err
|
return args.Get(0).(io.ReadCloser), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cm *containerMock) DumpLogs(ctx context.Context) error {
|
||||||
|
return cm.Called(ctx).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
||||||
|
args := cm.Called(ctx)
|
||||||
|
info, _ := args.Get(0).(*container.Info)
|
||||||
|
err, _ := args.Get(1).(error)
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|||||||
+23
-224
@@ -17,17 +17,19 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/expreval"
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExpressionEvaluator is the interface for evaluating expressions
|
// ExpressionEvaluator is the interface for evaluating expressions
|
||||||
type ExpressionEvaluator interface {
|
type ExpressionEvaluator interface {
|
||||||
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
||||||
|
interpolate(context.Context, string) (string, error)
|
||||||
EvaluateYamlNode(context.Context, *yaml.Node) error
|
EvaluateYamlNode(context.Context, *yaml.Node) error
|
||||||
Interpolate(context.Context, string) string
|
Interpolate(context.Context, string) string
|
||||||
}
|
}
|
||||||
@@ -232,240 +234,37 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
|||||||
return evaluated, err
|
return evaluated, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
// shared returns the evaluation layer of the shared library, bound to this context so the
|
||||||
var in string
|
// evaluation of every single expression is still logged and masked here.
|
||||||
if err := node.Decode(&in); err != nil {
|
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
||||||
return nil, err
|
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||||
}
|
return ee.evaluate(ctx, in, defaultStatusCheck)
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
})
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
expr, _ := rewriteSubExpression(ctx, in, false)
|
|
||||||
res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret := &yaml.Node{}
|
|
||||||
if err := ret.Encode(res); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
|
||||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
|
||||||
for i := 0; i < len(node.Content)/2; i++ {
|
|
||||||
changed := func() error {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i*2]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
k := node.Content[i*2]
|
|
||||||
v := node.Content[i*2+1]
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev = v
|
|
||||||
}
|
|
||||||
var sk string
|
|
||||||
// Merge the nested map of the insert directive
|
|
||||||
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
|
||||||
if ev.Kind != yaml.MappingNode {
|
|
||||||
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
|
|
||||||
}
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ek, err := ee.evaluateYamlNodeInternal(ctx, k)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ek != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ek = k
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, ek, ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
for i := 0; i < len(node.Content); i++ {
|
|
||||||
v := node.Content[i]
|
|
||||||
// Preserve nested sequences
|
|
||||||
wasseq := v.Kind == yaml.SequenceNode
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i]
|
|
||||||
}
|
|
||||||
// GitHub has this undocumented feature to merge sequences / arrays
|
|
||||||
// We have a nested sequence via evaluation, merge the arrays
|
|
||||||
if ev.Kind == yaml.SequenceNode && !wasseq {
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ret.Content = append(ret.Content, ev)
|
|
||||||
}
|
|
||||||
} else if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
switch node.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
return ee.evaluateScalarYamlNode(ctx, node)
|
|
||||||
case yaml.MappingNode:
|
|
||||||
return ee.evaluateMappingYamlNode(ctx, node)
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
return ee.evaluateSequenceYamlNode(ctx, node)
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
||||||
ret, err := ee.evaluateYamlNodeInternal(ctx, node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
return ret.Decode(node)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
out, err := ee.interpolate(ctx, in)
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
expr, _ := rewriteSubExpression(ctx, in, true)
|
|
||||||
evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err)
|
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
value, ok := evaluated.(string)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator
|
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||||
|
return ee.shared(ctx).Interpolate(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
|
// `${{ }}`, while literal text around one makes the whole value a string.
|
||||||
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
||||||
nextExpr, _ := rewriteSubExpression(ctx, expr, false)
|
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
||||||
|
return evaluator.evaluate(ctx, in, dsc)
|
||||||
evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
|
}).EvalBool(expr, defaultStatusCheck)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return exprparser.IsTruthy(evaluated), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeFormatString(in string) string {
|
|
||||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
|
||||||
pos := 0
|
|
||||||
exprStart := -1
|
|
||||||
strStart := -1
|
|
||||||
var results []string
|
|
||||||
var formatOut strings.Builder
|
|
||||||
for pos < len(in) {
|
|
||||||
if strStart > -1 {
|
|
||||||
matches := strPattern.FindStringIndex(in[pos:])
|
|
||||||
if matches == nil {
|
|
||||||
panic("unclosed string.")
|
|
||||||
}
|
|
||||||
|
|
||||||
strStart = -1
|
|
||||||
pos += matches[1]
|
|
||||||
} else if exprStart > -1 {
|
|
||||||
exprEnd := strings.Index(in[pos:], "}}")
|
|
||||||
strStart = strings.Index(in[pos:], "'")
|
|
||||||
|
|
||||||
if exprEnd > -1 && strStart > -1 {
|
|
||||||
if exprEnd < strStart {
|
|
||||||
strStart = -1
|
|
||||||
} else {
|
|
||||||
exprEnd = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if exprEnd > -1 {
|
|
||||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
|
||||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
|
||||||
pos += exprEnd + 2
|
|
||||||
exprStart = -1
|
|
||||||
} else if strStart > -1 {
|
|
||||||
pos += strStart + 1
|
|
||||||
} else {
|
|
||||||
panic("unclosed expression.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
exprStart = strings.Index(in[pos:], "${{")
|
|
||||||
if exprStart != -1 {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
|
||||||
exprStart = pos + exprStart + 3
|
|
||||||
pos = exprStart
|
|
||||||
} else {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
|
||||||
pos = len(in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
|
||||||
if in != out {
|
|
||||||
common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
@@ -259,6 +258,15 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
||||||
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
||||||
{"${{ fromJSON('{}') < 2 }}", "false"},
|
{"${{ fromJSON('{}') < 2 }}", "false"},
|
||||||
|
{"${{ 1 }}", "1"},
|
||||||
|
{"${{ 1.0 }}", "1"},
|
||||||
|
{"${{ null }}", ""},
|
||||||
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
|
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
||||||
|
{"${{ 1) && (2 }}", ""},
|
||||||
|
{"run ${{ 1) && (2 }} now", ""},
|
||||||
|
{"${{ 1", "${{ 1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -270,60 +278,6 @@ func TestInterpolate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRewriteSubExpression(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
in string
|
|
||||||
out string
|
|
||||||
}{
|
|
||||||
{in: "Hello World", out: "Hello World"},
|
|
||||||
{in: "${{ true }}", out: "${{ true }}"},
|
|
||||||
{in: "${{ true }} ${{ true }}", out: "format('{0} {1}', true, true)"},
|
|
||||||
{in: "${{ true || false }} ${{ true && true }}", out: "format('{0} {1}', true || false, true && true)"},
|
|
||||||
{in: "${{ '}}' }}", out: "${{ '}}' }}"},
|
|
||||||
{in: "${{ '''}}''' }}", out: "${{ '''}}''' }}"},
|
|
||||||
{in: "${{ '''' }}", out: "${{ '''' }}"},
|
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `${{ fromJSON('"}}"') }}`},
|
|
||||||
{in: `${{ fromJSON('"\"}}\""') }}`, out: `${{ fromJSON('"\"}}\""') }}`},
|
|
||||||
{in: `${{ fromJSON('"''}}"') }}`, out: `${{ fromJSON('"''}}"') }}`},
|
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range table {
|
|
||||||
t.Run("TestRewriteSubExpression", func(t *testing.T) {
|
|
||||||
assertObject := assert.New(t)
|
|
||||||
out, err := rewriteSubExpression(context.Background(), table.in, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertObject.Equal(table.out, out, table.in)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteSubExpressionForceFormat(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
in string
|
|
||||||
out string
|
|
||||||
}{
|
|
||||||
{in: "Hello World", out: "Hello World"},
|
|
||||||
{in: "${{ true }}", out: "format('{0}', true)"},
|
|
||||||
{in: "${{ '}}' }}", out: "format('{0}', '}}')"},
|
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `format('{0}', fromJSON('"}}"'))`},
|
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range table {
|
|
||||||
t.Run("TestRewriteSubExpressionForceFormat", func(t *testing.T) {
|
|
||||||
assertObject := assert.New(t)
|
|
||||||
out, err := rewriteSubExpression(context.Background(), table.in, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertObject.Equal(table.out, out, table.in)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
workflows := map[string]string{
|
workflows := map[string]string{
|
||||||
"workflow_call": `
|
"workflow_call": `
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
@@ -66,7 +67,7 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
|||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Logger(ctx).Errorf("##[error]%s", escapeCommandData(err.Error()))
|
common.Logger(ctx).Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
rc.markFailed()
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
@@ -260,7 +261,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||||
if err = info.stopContainer()(ctx); err != nil {
|
if err = info.stopContainer()(ctx); err != nil {
|
||||||
logger.Errorf("Error while stop job container: %v", err)
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
cmd, shell := hookCommand(hookPath)
|
cmd, shell := hookCommand(hookPath)
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||||
if shell != "" {
|
if shell != "" {
|
||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
|||||||
ret = append(ret, tm, "***")
|
ret = append(ret, tm, "***")
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||||
if strings.ContainsAny(tm, "%\r\n") {
|
if strings.ContainsAny(tm, "%\r\n") {
|
||||||
ret = append(ret, escapeCommandData(tm), "***")
|
ret = append(ret, EscapeCommandData(tm), "***")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+238
-25
@@ -22,18 +22,21 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunContext contains info about current job
|
// RunContext contains info about current job
|
||||||
@@ -55,7 +58,7 @@ type RunContext struct {
|
|||||||
IntraActionState map[string]map[string]string
|
IntraActionState map[string]map[string]string
|
||||||
ExprEval ExpressionEvaluator
|
ExprEval ExpressionEvaluator
|
||||||
JobContainer container.ExecutionsEnvironment
|
JobContainer container.ExecutionsEnvironment
|
||||||
ServiceContainers []container.ExecutionsEnvironment
|
serviceContainers []*serviceContainer
|
||||||
OutputMappings map[MappableOutput]MappableOutput
|
OutputMappings map[MappableOutput]MappableOutput
|
||||||
JobName string
|
JobName string
|
||||||
ActionPath string
|
ActionPath string
|
||||||
@@ -83,6 +86,37 @@ type RunContext struct {
|
|||||||
// failures. Those failures must still make success() false and failure() true for later
|
// failures. Those failures must still make success() false and failure() true for later
|
||||||
// main-step if evaluation.
|
// main-step if evaluation.
|
||||||
jobFailed bool
|
jobFailed bool
|
||||||
|
// empty for a host-mode job, which starts no container
|
||||||
|
jobContainerID string
|
||||||
|
jobNetworkName string
|
||||||
|
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||||
|
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||||
|
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||||
|
stepEnv map[string]string
|
||||||
|
unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure
|
||||||
|
unsecureCommandMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceContainer pairs a service container with the workflow id that keys job.services.
|
||||||
|
type serviceContainer struct {
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
container container.ExecutionsEnvironment
|
||||||
|
logsDumped bool
|
||||||
|
info *container.Info // last poll, the source of the `job.services` entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCurrentStepEnv records the environment of the step about to run.
|
||||||
|
func (rc *RunContext) setCurrentStepEnv(env map[string]string) {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
rc.stepEnv = env
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) currentStepEnv() map[string]string {
|
||||||
|
rc.unsecureCommandMu.Lock()
|
||||||
|
defer rc.unsecureCommandMu.Unlock()
|
||||||
|
return rc.stepEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
||||||
@@ -195,14 +229,19 @@ func (rc *RunContext) containerDaemonSocket() string {
|
|||||||
return rc.Config.ContainerDaemonSocket
|
return rc.Config.ContainerDaemonSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared
|
||||||
|
|
||||||
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
||||||
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
||||||
// never mutates the shared Config (see containerDaemonSocket).
|
// never mutates the shared Config (see containerDaemonSocket).
|
||||||
func (rc *RunContext) validVolumes() []string {
|
func (rc *RunContext) validVolumes() []string {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
volumes := slices.Clone(rc.Config.ValidVolumes)
|
||||||
|
if rc.Config.SharedToolCache {
|
||||||
|
volumes = append(volumes, sharedToolCacheVolume)
|
||||||
|
}
|
||||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||||
return append(volumes, "act-toolcache", name, name+"-env",
|
return append(volumes, name, name+"-env",
|
||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +314,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
}
|
}
|
||||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
if rc.Config.SharedToolCache {
|
||||||
mounts["act-toolcache"] = toolCache
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
|
mounts[sharedToolCacheVolume] = toolCache
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
@@ -326,7 +367,11 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
|
toolCacheParent := miscpath // per job, so cleanup removes it with the job
|
||||||
|
if rc.Config.SharedToolCache {
|
||||||
|
toolCacheParent = cacheDir
|
||||||
|
}
|
||||||
|
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
||||||
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -488,7 +533,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Privileged: rc.Config.Privileged,
|
Privileged: rc.Config.Privileged,
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||||
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
|
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
|
||||||
NetworkMode: networkName,
|
NetworkMode: networkName,
|
||||||
NetworkAliases: []string{serviceID},
|
NetworkAliases: []string{serviceID},
|
||||||
@@ -496,7 +541,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
PortBindings: portBindings,
|
PortBindings: portBindings,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||||
@@ -531,6 +576,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return errors.New("Failed to create job container")
|
return errors.New("Failed to create job container")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rc.jobNetworkName = networkName
|
||||||
|
|
||||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.pullServicesImages(rc.Config.ForcePull),
|
rc.pullServicesImages(rc.Config.ForcePull),
|
||||||
@@ -538,9 +585,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
rc.stopJobContainer(),
|
rc.stopJobContainer(),
|
||||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||||
IfBool(createAndDeleteNetwork),
|
IfBool(createAndDeleteNetwork),
|
||||||
rc.startServiceContainers(networkName),
|
rc.startServiceContainers(),
|
||||||
|
rc.reportUnstartedServices(),
|
||||||
|
rc.waitForServiceContainers(),
|
||||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
rc.JobContainer.Start(false),
|
rc.JobContainer.Start(false),
|
||||||
|
rc.captureJobContainerInfo(),
|
||||||
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
|
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
|
||||||
Name: "workflow/event.json",
|
Name: "workflow/event.json",
|
||||||
Mode: 0o644,
|
Mode: 0o644,
|
||||||
@@ -565,7 +615,7 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
if removeJobContainer {
|
if removeJobContainer {
|
||||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||||
}
|
}
|
||||||
if len(rc.ServiceContainers) > 0 {
|
if len(rc.serviceContainers) > 0 {
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||||
logger.Errorf("Error while cleaning services: %v", err)
|
logger.Errorf("Error while cleaning services: %v", err)
|
||||||
@@ -662,21 +712,21 @@ func (rc *RunContext) stopJobContainer() common.Executor {
|
|||||||
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
for _, c := range rc.ServiceContainers {
|
for _, svc := range rc.serviceContainers {
|
||||||
execs = append(execs, c.Pull(forcePull))
|
execs = append(execs, svc.container.Pull(forcePull))
|
||||||
}
|
}
|
||||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startServiceContainers(_ string) common.Executor {
|
func (rc *RunContext) startServiceContainers() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
for _, c := range rc.ServiceContainers {
|
for _, svc := range rc.serviceContainers {
|
||||||
execs = append(execs, common.NewPipelineExecutor(
|
execs = append(execs, common.NewPipelineExecutor(
|
||||||
c.Pull(false),
|
svc.container.Pull(false),
|
||||||
c.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
c.Start(false),
|
svc.container.Start(false),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||||
@@ -686,13 +736,159 @@ func (rc *RunContext) startServiceContainers(_ string) common.Executor {
|
|||||||
func (rc *RunContext) stopServiceContainers() common.Executor {
|
func (rc *RunContext) stopServiceContainers() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
for _, c := range rc.ServiceContainers {
|
for _, svc := range rc.serviceContainers {
|
||||||
execs = append(execs, c.Remove().Finally(c.Close()))
|
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
|
||||||
}
|
}
|
||||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultServiceReadyTimeout = 5 * time.Minute
|
||||||
|
serviceReadyPollMax = 32 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait
|
||||||
|
|
||||||
|
// reportUnstartedServices logs a service that did not start. The steps that need it
|
||||||
|
// report it better than the runner can, so the job carries on.
|
||||||
|
func (rc *RunContext) reportUnstartedServices() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
for _, svc := range rc.serviceContainers {
|
||||||
|
info, err := svc.inspect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("unable to inspect service '%s': %v", svc.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.State == container.StateRunning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
svc.dumpLogs(ctx)
|
||||||
|
logger.Warnf("Docker container %s is not in running state: %s (%d)", info.ID, info.State, info.ExitCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForServiceContainers blocks until every service that declares a healthcheck reports
|
||||||
|
// healthy, as GitHub does, so a first step cannot connect before the service listens.
|
||||||
|
func (rc *RunContext) waitForServiceContainers() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if len(rc.serviceContainers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := rc.Config.ServiceReadyTimeout
|
||||||
|
switch {
|
||||||
|
case timeout < 0:
|
||||||
|
// disabled, but still describe the containers for `job.services`
|
||||||
|
for _, svc := range rc.serviceContainers {
|
||||||
|
if _, err := svc.inspect(ctx); err != nil && !errors.Is(err, container.ErrContainerNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case timeout == 0:
|
||||||
|
timeout = defaultServiceReadyTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first error cancels the rest, so a failure does not wait out a sibling's timeout
|
||||||
|
group, groupCtx := errgroup.WithContext(ctx)
|
||||||
|
for _, svc := range rc.serviceContainers {
|
||||||
|
group.Go(func() error {
|
||||||
|
return svc.waitUntilHealthy(groupCtx, timeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return group.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitUntilHealthy waits on the healthcheck alone, so a container that declares none is
|
||||||
|
// ready at once and one that exited is left to the steps that need it.
|
||||||
|
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
interval := serviceReadyPollInterval
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
info, err := svc.inspect(ctx)
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil { // the wait ended, an inspect error only noticed it
|
||||||
|
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
||||||
|
return fmt.Errorf("the service '%s' did not become healthy within %s%s", svc.name, timeout, svc.healthOutputSuffix())
|
||||||
|
}
|
||||||
|
return ctxErr
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, container.ErrContainerNotFound):
|
||||||
|
return nil // gone, so there is no health left to wait on
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case info.Health == container.HealthUnhealthy:
|
||||||
|
svc.dumpLogs(ctx)
|
||||||
|
common.Logger(ctx).Errorf("Failed to initialize container %s", svc.image)
|
||||||
|
return fmt.Errorf("the service '%s' is unhealthy%s", svc.name, svc.healthOutputSuffix())
|
||||||
|
case info.Health != container.HealthStarting:
|
||||||
|
rawLogger.Infof("%s service is healthy.", svc.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds()))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done(): // reported at the top of the loop
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
interval = min(interval*2, serviceReadyPollMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpLogs writes the container's log to the job log once, however often it is reported.
|
||||||
|
func (svc *serviceContainer) dumpLogs(ctx context.Context) {
|
||||||
|
if svc.logsDumped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc.logsDumped = true
|
||||||
|
if err := svc.container.DumpLogs(ctx); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("unable to read the log of service '%s': %v", svc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspect also records the state for the `job.services` context.
|
||||||
|
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
|
||||||
|
info, err := svc.container.Inspect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to inspect service '%s': %w", svc.name, err)
|
||||||
|
}
|
||||||
|
svc.info = info
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *serviceContainer) healthOutputSuffix() string {
|
||||||
|
if svc.info == nil || svc.info.HealthOutput == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ": " + svc.info.HealthOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureJobContainerInfo is a convenience: failing to describe the container must not
|
||||||
|
// fail the job.
|
||||||
|
func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
info, err := rc.JobContainer.Inspect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Debugf("unable to inspect the job container: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rc.jobContainerID = info.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare the mounts and binds for the worker
|
// Prepare the mounts and binds for the worker
|
||||||
|
|
||||||
// ActionCacheDir is for rc
|
// ActionCacheDir is for rc
|
||||||
@@ -1033,9 +1229,26 @@ func (rc *RunContext) getJobContext() *model.JobContext {
|
|||||||
if rc.jobCancelled {
|
if rc.jobCancelled {
|
||||||
jobStatus = "cancelled"
|
jobStatus = "cancelled"
|
||||||
}
|
}
|
||||||
return &model.JobContext{
|
|
||||||
Status: jobStatus,
|
jobContext := &model.JobContext{
|
||||||
|
Status: jobStatus,
|
||||||
|
Services: map[string]model.JobService{}, // an empty map, never null
|
||||||
}
|
}
|
||||||
|
if rc.jobContainerID != "" {
|
||||||
|
jobContext.Container.ID = rc.jobContainerID
|
||||||
|
jobContext.Container.Network = rc.jobNetworkName
|
||||||
|
}
|
||||||
|
for _, svc := range rc.serviceContainers {
|
||||||
|
if svc.info == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobContext.Services[svc.name] = model.JobService{
|
||||||
|
ID: svc.info.ID,
|
||||||
|
Network: rc.jobNetworkName,
|
||||||
|
Ports: svc.info.Ports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jobContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
||||||
@@ -1154,12 +1367,12 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetBaseAndHeadRef()
|
ghc.SetBaseAndHeadRef()
|
||||||
repoPath := rc.Config.Workdir
|
repoPath := rc.Config.Workdir
|
||||||
ghc.SetRepositoryAndOwner(ctx, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ghc.SetRef(ctx, rc.Config.DefaultBranch, repoPath)
|
ghcontext.SetRef(ctx, ghc, rc.Config.DefaultBranch, repoPath)
|
||||||
}
|
}
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
ghc.SetSha(ctx, repoPath)
|
ghcontext.SetSha(ctx, ghc, repoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
@@ -225,6 +227,12 @@ func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor {
|
|||||||
return func(context.Context) error { return nil }
|
return func(context.Context) error { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
|
||||||
|
return &container.Info{ID: "fake", State: "running", Health: container.HealthNone}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
||||||
|
|
||||||
// Regression test: a service without a `credentials:` block resolves to empty
|
// Regression test: a service without a `credentials:` block resolves to empty
|
||||||
// credentials, which used to overwrite the job container's own credentials.
|
// credentials, which used to overwrite the job container's own credentials.
|
||||||
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
||||||
@@ -494,7 +502,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
|
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
@@ -535,25 +544,44 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ToolCacheMount", func(t *testing.T) {
|
||||||
|
rc := &RunContext{
|
||||||
|
Name: "TestRCName",
|
||||||
|
Run: &model.Run{Workflow: &model.Workflow{Name: "TestWorkflowName"}},
|
||||||
|
Config: &Config{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, gotmount := rc.GetBindsAndMounts()
|
||||||
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
|
||||||
|
rc.Config.SharedToolCache = true
|
||||||
|
_, gotmount = rc.GetBindsAndMounts()
|
||||||
|
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
func TestRunContextValidVolumes(t *testing.T) {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "job",
|
Name: "job",
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
||||||
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
|
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}, SharedToolCache: true},
|
||||||
}
|
}
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
got := rc.validVolumes()
|
got := rc.validVolumes()
|
||||||
|
|
||||||
// the configured volumes plus the four the runner mounts automatically
|
// the configured volumes plus the ones the runner mounts automatically
|
||||||
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
|
assert.Subset(t, got, []string{"my-vol", "/host/path", sharedToolCacheVolume, name, name + "-env", "/var/run/docker.sock"})
|
||||||
|
|
||||||
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
||||||
// combinations share one *Config, and the previous in-place append was a data race.
|
// combinations share one *Config, and the previous in-place append was a data race.
|
||||||
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||||
|
|
||||||
|
// a job may mount it only while the runner does
|
||||||
|
rc.Config.SharedToolCache = false
|
||||||
|
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -563,7 +591,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
|||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
||||||
@@ -586,7 +614,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
|||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||||
JobContainer: jobContainer,
|
JobContainer: jobContainer,
|
||||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -1045,6 +1073,199 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWaitForServiceContainers(t *testing.T) {
|
||||||
|
origInterval := serviceReadyPollInterval
|
||||||
|
serviceReadyPollInterval = time.Millisecond
|
||||||
|
defer func() { serviceReadyPollInterval = origInterval }()
|
||||||
|
|
||||||
|
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
|
||||||
|
return &RunContext{
|
||||||
|
Config: &Config{ServiceReadyTimeout: timeout},
|
||||||
|
serviceContainers: services,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("returns as soon as a service without a healthcheck runs", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(0, &serviceContainer{name: "redis", container: service})
|
||||||
|
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||||
|
service.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("waits while a service is still starting", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
|
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||||
|
service.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).Return(&container.Info{
|
||||||
|
State: "running",
|
||||||
|
Health: container.HealthUnhealthy,
|
||||||
|
HealthOutput: "connection refused",
|
||||||
|
}, nil).Once()
|
||||||
|
service.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
|
err := rc.waitForServiceContainers()(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused")
|
||||||
|
service.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lets the steps run when a service exits without a healthcheck", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
|
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("proceeds when the container is gone", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return((*container.Info)(nil), container.ErrContainerNotFound).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
|
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails right away when one service fails while another is still starting", func(t *testing.T) {
|
||||||
|
failing := &containerMock{}
|
||||||
|
failing.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{State: "running", Health: container.HealthUnhealthy}, nil)
|
||||||
|
failing.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||||
|
starting := &containerMock{}
|
||||||
|
starting.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||||
|
|
||||||
|
rc := newRunContext(10*time.Second,
|
||||||
|
&serviceContainer{name: "failing", container: failing},
|
||||||
|
&serviceContainer{name: "starting", container: starting})
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- rc.waitForServiceContainers()(context.Background()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "the service 'failing' is unhealthy")
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("waitForServiceContainers did not fail fast; it waited for the starting service")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gives up once the timeout expires", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||||
|
|
||||||
|
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||||
|
err := rc.waitForServiceContainers()(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gives up with the same message when the deadline stops an inspect", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Run(func(args mock.Arguments) { <-args.Get(0).(context.Context).Done() }).
|
||||||
|
Return((*container.Info)(nil), errors.New("inspect aborted"))
|
||||||
|
|
||||||
|
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||||
|
err := rc.waitForServiceContainers()(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not wait when the timeout is negative", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
// Still described once, so the `job.services` context is filled either way.
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Once()
|
||||||
|
|
||||||
|
svc := &serviceContainer{name: "postgres", container: service}
|
||||||
|
rc := newRunContext(-1, svc)
|
||||||
|
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||||
|
service.AssertExpectations(t)
|
||||||
|
assert.Equal(t, "id", svc.info.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails on an inspect error even when the timeout is negative", func(t *testing.T) {
|
||||||
|
service := &containerMock{}
|
||||||
|
service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once()
|
||||||
|
|
||||||
|
rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service})
|
||||||
|
err := rc.waitForServiceContainers()(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to inspect service 'postgres'")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is a no-op without services", func(t *testing.T) {
|
||||||
|
require.NoError(t, newRunContext(0).waitForServiceContainers()(context.Background()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportUnstartedServices(t *testing.T) {
|
||||||
|
dead := &containerMock{}
|
||||||
|
dead.On("Inspect", mock.Anything).Return(&container.Info{ID: "dead-id", State: "exited", ExitCode: 1}, nil).Once()
|
||||||
|
dead.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||||
|
running := &containerMock{}
|
||||||
|
running.On("Inspect", mock.Anything).Return(&container.Info{ID: "run-id", State: "running"}, nil).Once()
|
||||||
|
|
||||||
|
rc := &RunContext{serviceContainers: []*serviceContainer{
|
||||||
|
{name: "postgres", container: dead},
|
||||||
|
{name: "redis", container: running},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.NoError(t, rc.reportUnstartedServices()(context.Background()))
|
||||||
|
dead.AssertExpectations(t)
|
||||||
|
running.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobContextReportsContainers(t *testing.T) {
|
||||||
|
rc := &RunContext{
|
||||||
|
jobNetworkName: "job-network",
|
||||||
|
jobContainerID: "job-container-id",
|
||||||
|
serviceContainers: []*serviceContainer{
|
||||||
|
{name: "postgres", info: &container.Info{ID: "svc-id", Ports: map[string]string{"5432": "49153"}}},
|
||||||
|
// A service that publishes no port reports an empty map, as GitHub does.
|
||||||
|
{name: "redis", info: &container.Info{ID: "redis-id", Ports: map[string]string{}}},
|
||||||
|
// A service that never reported is left out rather than reported as empty.
|
||||||
|
{name: "mailhog"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jobContext := rc.getJobContext()
|
||||||
|
|
||||||
|
assert.Equal(t, "job-container-id", jobContext.Container.ID)
|
||||||
|
assert.Equal(t, "job-network", jobContext.Container.Network)
|
||||||
|
assert.Equal(t, map[string]model.JobService{
|
||||||
|
"postgres": {ID: "svc-id", Network: "job-network", Ports: map[string]string{"5432": "49153"}},
|
||||||
|
"redis": {ID: "redis-id", Network: "job-network", Ports: map[string]string{}},
|
||||||
|
}, jobContext.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A job that never started a container reports an empty context, not a placeholder.
|
||||||
|
func TestGetJobContextWithoutContainer(t *testing.T) {
|
||||||
|
jobContext := (&RunContext{}).getJobContext()
|
||||||
|
|
||||||
|
assert.Empty(t, jobContext.Container.ID)
|
||||||
|
assert.Empty(t, jobContext.Container.Network)
|
||||||
|
assert.Empty(t, jobContext.Services)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageOSFromImage(t *testing.T) {
|
func TestImageOSFromImage(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
image string
|
image string
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -74,6 +74,7 @@ type Config struct {
|
|||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||||
|
PatchToolkit bool // edit the @actions toolkit bundled into an action so it works against Gitea, see toolkit_patch.go
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||||
@@ -90,9 +91,11 @@ type Config struct {
|
|||||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
JobLoggerLevel *log.Level // the level of job logger
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
|
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
@@ -312,7 +312,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||||
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
{workdir, "container-volumes", "push", "", platforms, secrets},
|
||||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
|
|||||||
+17
-3
@@ -15,8 +15,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type step interface {
|
type step interface {
|
||||||
@@ -165,9 +166,22 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
|
|
||||||
|
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||||
|
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||||
|
// different goroutine from the command handler that reads it.
|
||||||
|
rc.setCurrentStepEnv(maps0.Clone(*step.getEnv()))
|
||||||
|
defer rc.setCurrentStepEnv(nil)
|
||||||
|
_ = rc.takeUnsecureCommandError() // a refusal from before any step belongs to no step
|
||||||
|
|
||||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||||
defer cancelTimeOut()
|
defer cancelTimeOut()
|
||||||
err = executor(timeoutctx)
|
err = executor(timeoutctx)
|
||||||
|
// Always take it, so the job-scoped error cannot leak onto a later step. A refusal
|
||||||
|
// fails the step as it does on GitHub, but the executor's own error wins.
|
||||||
|
insecureErr := rc.takeUnsecureCommandError()
|
||||||
|
if err == nil {
|
||||||
|
err = insecureErr
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
||||||
@@ -181,7 +195,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if continueOnError {
|
if continueOnError {
|
||||||
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
logger.Infof("Failed but continue next step")
|
logger.Infof("Failed but continue next step")
|
||||||
err = nil
|
err = nil
|
||||||
stepResult.Conclusion = model.StepStatusSuccess
|
stepResult.Conclusion = model.StepStatusSuccess
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepActionLocal struct {
|
type stepActionLocal struct {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ type stepActionRemote struct {
|
|||||||
|
|
||||||
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
||||||
|
|
||||||
|
// selfRepoPrefix introduces a self-repository reference: the action lives in the repo holding the file that wrote the `uses:`.
|
||||||
|
const selfRepoPrefix = "$/"
|
||||||
|
|
||||||
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if sar.remoteAction != nil && sar.action != nil {
|
if sar.remoteAction != nil && sar.action != nil {
|
||||||
@@ -52,12 +55,16 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
// so we need to interpolate the expression value for uses first.
|
// so we need to interpolate the expression value for uses first.
|
||||||
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||||
|
|
||||||
sar.remoteAction = newRemoteAction(sar.Step.Uses)
|
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||||
|
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
||||||
|
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github)
|
||||||
|
} else {
|
||||||
|
sar.remoteAction = newRemoteAction(sar.Step.Uses)
|
||||||
|
}
|
||||||
if sar.remoteAction == nil {
|
if sar.remoteAction == nil {
|
||||||
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses)
|
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses)
|
||||||
}
|
}
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx)
|
|
||||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
||||||
return nil
|
return nil
|
||||||
@@ -236,10 +243,10 @@ func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
|||||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea, which lets the cache
|
// patchActionToolkit edits the bundled toolkit so it works against Gitea: the artifact actions
|
||||||
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
// stop refusing, and the cache client keeps to the cache server whichever API version it picks.
|
||||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||||
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
if sar.RunContext.Config.PatchToolkit {
|
||||||
dir, scripts := sar.toolkitBundles()
|
dir, scripts := sar.toolkitBundles()
|
||||||
patchToolkit(ctx, dir, scripts)
|
patchToolkit(ctx, dir, scripts)
|
||||||
}
|
}
|
||||||
@@ -260,7 +267,12 @@ func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) actionDir() string {
|
func (sar *stepActionRemote) actionDir() string {
|
||||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
uses := sar.Step.Uses
|
||||||
|
if strings.HasPrefix(uses, selfRepoPrefix) {
|
||||||
|
// The same `$/x` names a different action per enclosing repo, so key the cache on what it resolved to.
|
||||||
|
uses = sar.remoteAction.URL + "/" + sar.remoteAction.Reference()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), model.UsesHash(uses))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||||
@@ -390,6 +402,23 @@ func (ra *remoteAction) IsCheckout() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit.
|
||||||
|
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction {
|
||||||
|
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/")
|
||||||
|
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
repo, ref := github.ActionRepository, github.ActionRef
|
||||||
|
if repo == "" || ref == "" {
|
||||||
|
repo, ref = github.Repository, github.Sha
|
||||||
|
}
|
||||||
|
org, name, _ := strings.Cut(repo, "/")
|
||||||
|
if org == "" || name == "" || ref == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref}
|
||||||
|
}
|
||||||
|
|
||||||
func newRemoteAction(action string) *remoteAction {
|
func newRemoteAction(action string) *remoteAction {
|
||||||
// support http(s)://host/owner/repo@v3
|
// support http(s)://host/owner/repo@v3
|
||||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -653,6 +653,8 @@ func TestStepActionRemotePost(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Step: tt.stepModel,
|
Step: tt.stepModel,
|
||||||
action: tt.actionModel,
|
action: tt.actionModel,
|
||||||
|
// post only ever runs after prepareActionExecutor resolved the action
|
||||||
|
remoteAction: newRemoteAction(tt.stepModel.Uses),
|
||||||
}
|
}
|
||||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
@@ -821,6 +823,73 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_newSelfRepoAction(t *testing.T) {
|
||||||
|
workflow := &model.GithubContext{
|
||||||
|
ServerURL: "https://gitea.example.com",
|
||||||
|
Repository: "owner/workflow-repo",
|
||||||
|
Sha: "abc123",
|
||||||
|
}
|
||||||
|
composite := &model.GithubContext{
|
||||||
|
ServerURL: "https://gitea.example.com",
|
||||||
|
Repository: "owner/workflow-repo",
|
||||||
|
Sha: "abc123",
|
||||||
|
ActionRepository: "other/action-repo",
|
||||||
|
ActionRef: "v1",
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action string
|
||||||
|
github *model.GithubContext
|
||||||
|
want *remoteAction
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "top level resolves to the workflow repo at its commit",
|
||||||
|
action: "$/.gitea/actions/build",
|
||||||
|
github: workflow,
|
||||||
|
want: &remoteAction{
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Org: "owner",
|
||||||
|
Repo: "workflow-repo",
|
||||||
|
Path: ".gitea/actions/build",
|
||||||
|
Ref: "abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside a composite resolves to the enclosing action",
|
||||||
|
action: "$/.gitea/actions/build",
|
||||||
|
github: composite,
|
||||||
|
want: &remoteAction{
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Org: "other",
|
||||||
|
Repo: "action-repo",
|
||||||
|
Path: ".gitea/actions/build",
|
||||||
|
Ref: "v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{name: "empty path", action: "$/", github: workflow},
|
||||||
|
{name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow},
|
||||||
|
{name: "path traversal", action: "$/../escape", github: workflow},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_stepActionRemoteSelfRepoActionDir(t *testing.T) {
|
||||||
|
dirFor := func(repo string) string {
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "$/.gitea/actions/build"},
|
||||||
|
RunContext: &RunContext{Config: &Config{ActionCacheDir: "/cache"}},
|
||||||
|
remoteAction: &remoteAction{Org: "owner", Repo: repo, Ref: "v1"},
|
||||||
|
}
|
||||||
|
return sar.actionDir()
|
||||||
|
}
|
||||||
|
// The same `$/x` in two repos must not share a cache directory.
|
||||||
|
assert.NotEqual(t, dirFor("one"), dirFor("two"))
|
||||||
|
}
|
||||||
|
|
||||||
func Test_remoteActionReference(t *testing.T) {
|
func Test_remoteActionReference(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
uses string
|
uses string
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepFactory interface {
|
type stepFactory interface {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -15,9 +15,10 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
|||||||
|
|
||||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||||
|
|
||||||
rawLogger.Infof("::group::Run %s", escapeCommandData(sr.runScriptGroupTitle(normalized)))
|
rawLogger.Infof("::group::Run %s", EscapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||||
|
|
||||||
if normalized != "" {
|
if normalized != "" {
|
||||||
for line := range strings.SplitSeq(normalized, "\n") {
|
for line := range strings.SplitSeq(normalized, "\n") {
|
||||||
@@ -90,12 +91,12 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
|||||||
if step.Name != "" {
|
if step.Name != "" {
|
||||||
title = step.Name
|
title = step.Name
|
||||||
}
|
}
|
||||||
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
|
rawLogger.Infof("::group::Run %s", EscapeCommandData(title))
|
||||||
|
|
||||||
if len(step.With) > 0 {
|
if len(step.With) > 0 {
|
||||||
rawLogger.Infof("with:")
|
rawLogger.Infof("with:")
|
||||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
||||||
rawLogger.Infof(" %s: %s", k, step.With[k])
|
logKeyedValue(rawLogger, k, step.With[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,17 @@ func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]str
|
|||||||
if caseInsensitive {
|
if caseInsensitive {
|
||||||
lookupKey = strings.ToUpper(k)
|
lookupKey = strings.ToUpper(k)
|
||||||
}
|
}
|
||||||
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
|
logKeyedValue(rawLogger, k, envLookup[lookupKey])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logKeyedValue prints one row per line of value: Gitea stores one log row per line, so an
|
||||||
|
// embedded newline would reach the user as a literal "\n".
|
||||||
|
func logKeyedValue(rawLogger *logrus.Entry, key, value string) {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||||
|
rawLogger.Infof(" %s: %s", key, lines[0])
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
rawLogger.Infof(" %s", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|||||||
+48
-1
@@ -6,14 +6,16 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,3 +356,48 @@ func TestIsContinueOnError(t *testing.T) {
|
|||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.Error(err)
|
assertObject.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A refused ::set-env::/::add-path:: records a job-scoped error. When the step that
|
||||||
|
// produced it also fails on its own, the refusal must be cleared at the step boundary, so
|
||||||
|
// it fails only that step and never leaks onto a later step that runs anyway (if: always()).
|
||||||
|
func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||||
|
cm := &containerMock{}
|
||||||
|
noop := func(context.Context) error { return nil }
|
||||||
|
cm.On("Copy", mock.Anything, mock.Anything).Return(noop)
|
||||||
|
cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noop)
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{Env: map[string]string{}},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "1",
|
||||||
|
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
StepResults: map[string]*model.StepResult{},
|
||||||
|
JobContainer: cm,
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||||
|
// Dryrun skips reading the path file back from the (mocked) container.
|
||||||
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
|
// A refusal parsed out of the job container's own output belongs to no step, so the
|
||||||
|
// first step must not be failed by it.
|
||||||
|
rc.commandHandler(ctx)("::set-env name=setup::y\n")
|
||||||
|
stepSetup := &stepRun{RunContext: rc, Step: &model.Step{ID: "setup"}, env: map[string]string{}}
|
||||||
|
require.NoError(t, runStepExecutor(stepSetup, stepStageMain, func(context.Context) error { return nil })(ctx))
|
||||||
|
|
||||||
|
// Step A refuses a ::set-env:: and then fails on its own.
|
||||||
|
stepA := &stepRun{RunContext: rc, Step: &model.Step{ID: "a"}, env: map[string]string{}}
|
||||||
|
errA := runStepExecutor(stepA, stepStageMain, func(context.Context) error {
|
||||||
|
rc.commandHandler(ctx)("::set-env name=x::y\n")
|
||||||
|
return errors.New("boom")
|
||||||
|
})(ctx)
|
||||||
|
// The step fails with its own error, not the refusal.
|
||||||
|
require.ErrorContains(t, errA, "boom")
|
||||||
|
|
||||||
|
// Step B runs despite step A's failure (if: always()) and issues no unsecure command;
|
||||||
|
// it must not inherit step A's refusal.
|
||||||
|
stepB := &stepRun{RunContext: rc, Step: &model.Step{ID: "b", If: yaml.Node{Value: "always()"}}, env: map[string]string{}}
|
||||||
|
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
||||||
|
require.NoError(t, errB)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ jobs:
|
|||||||
_:
|
_:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||||
MYGLOBALENV3: myglobalval3
|
MYGLOBALENV3: myglobalval3
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
+2
@@ -4,6 +4,8 @@ on: push
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: TEST set-env
|
- name: TEST set-env
|
||||||
run: echo "::set-env name=foo::bar"
|
run: echo "::set-env name=foo::bar"
|
||||||
|
|||||||
Vendored
+6
@@ -15,3 +15,9 @@ jobs:
|
|||||||
echo "id: ${{ job.services.postgres.id }}"
|
echo "id: ${{ job.services.postgres.id }}"
|
||||||
echo "network: ${{ job.services.postgres.network }}"
|
echo "network: ${{ job.services.postgres.network }}"
|
||||||
echo "ports: ${{ job.services.postgres.ports }}"
|
echo "ports: ${{ job.services.postgres.ports }}"
|
||||||
|
- name: The job context describes the started containers
|
||||||
|
run: |
|
||||||
|
test -n "${{ job.container.id }}"
|
||||||
|
test -n "${{ job.services.postgres.id }}"
|
||||||
|
test -n "${{ job.services.postgres.ports['80'] }}"
|
||||||
|
test "${{ job.services.postgres.network }}" = "${{ job.container.network }}"
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -59,41 +63,65 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
|||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
// jobEnv is the environment a job gets from this runner, which is what decides where an action's
|
||||||
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
// toolkit looks for the cache and artifact services.
|
||||||
// so only the patch can make the client choose v2 and find the cache server.
|
type jobEnv struct {
|
||||||
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
workspace, runnerTemp string
|
||||||
|
cacheURL, resultsURL string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests
|
||||||
|
// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose
|
||||||
|
// names are the ones the action's own action.yml uses.
|
||||||
|
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
state := filepath.Join(runnerTemp, "state")
|
state := filepath.Join(env.runnerTemp, "state")
|
||||||
output := filepath.Join(runnerTemp, "output")
|
output := filepath.Join(env.runnerTemp, "output")
|
||||||
for _, name := range []string{state, output} {
|
for _, name := range []string{state, output} {
|
||||||
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(t.Context(), "node", script)
|
cmd := exec.CommandContext(t.Context(), "node", script)
|
||||||
cmd.Dir = workspace
|
cmd.Dir = env.workspace
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"INPUT_PATH=to-cache",
|
"ACTIONS_RUNTIME_TOKEN="+env.token,
|
||||||
"INPUT_KEY="+key,
|
"ACTIONS_CACHE_URL="+env.cacheURL+"/",
|
||||||
"ACTIONS_RUNTIME_TOKEN="+token,
|
"ACTIONS_RESULTS_URL="+env.resultsURL,
|
||||||
"ACTIONS_CACHE_URL="+cacheURL+"/",
|
|
||||||
// Unreachable on purpose: the artifact service lives here, the cache service must not.
|
|
||||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
|
||||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||||
"GITHUB_SERVER_URL=https://gitea.example.com",
|
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||||
|
"GITHUB_REPOSITORY=testuser/testrepo",
|
||||||
|
"GITHUB_RUN_ID=1",
|
||||||
"GITHUB_REF=refs/heads/main",
|
"GITHUB_REF=refs/heads/main",
|
||||||
"GITHUB_EVENT_NAME=push",
|
"GITHUB_EVENT_NAME=push",
|
||||||
"GITHUB_WORKSPACE="+workspace,
|
"GITHUB_WORKSPACE="+env.workspace,
|
||||||
"RUNNER_TEMP="+runnerTemp,
|
"RUNNER_TEMP="+env.runnerTemp,
|
||||||
"GITHUB_STATE="+state,
|
"GITHUB_STATE="+state,
|
||||||
"GITHUB_OUTPUT="+output,
|
"GITHUB_OUTPUT="+output,
|
||||||
)
|
)
|
||||||
|
for name, value := range inputs {
|
||||||
|
cmd.Env = append(cmd.Env, "INPUT_"+strings.ToUpper(name)+"="+value)
|
||||||
|
}
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
t.Logf("%s:\n%s", filepath.Base(script), out)
|
||||||
require.NoError(t, err, "%s failed", script)
|
require.NoError(t, err, "%s failed", script)
|
||||||
return string(out)
|
return string(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be,
|
||||||
|
// keeping the untouched original in the sidecar beside it.
|
||||||
|
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir := tempDirPath(t)
|
||||||
|
script := filepath.Join(dir, filepath.Base(entrypoint))
|
||||||
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
||||||
|
patchToolkit(t.Context(), dir, []string{script})
|
||||||
|
return script
|
||||||
|
}
|
||||||
|
|
||||||
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
||||||
// to /private/var and the client derives archive paths relative to the workspace.
|
// to /private/var and the client derives archive paths relative to the workspace.
|
||||||
func tempDirPath(t *testing.T) string {
|
func tempDirPath(t *testing.T) string {
|
||||||
@@ -113,43 +141,52 @@ func tempDirPath(t *testing.T) string {
|
|||||||
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
||||||
requireHostTools(t, "node")
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
// A stand-in action directory, patched exactly as a downloaded one would be.
|
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
|
||||||
actionDir := tempDirPath(t)
|
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
|
||||||
scripts := map[string]string{}
|
|
||||||
for _, stage := range []string{"restore", "save"} {
|
|
||||||
body, err := os.ReadFile(bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/"+stage+"/index.js"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
scripts[stage] = filepath.Join(actionDir, stage+".js")
|
|
||||||
require.NoError(t, os.WriteFile(scripts[stage], body, 0o600))
|
|
||||||
}
|
|
||||||
patchToolkit(t.Context(), actionDir, []string{scripts["restore"], scripts["save"]})
|
|
||||||
|
|
||||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = handler.Close() })
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||||
handler.RegisterJob(token, repo)
|
handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo})
|
||||||
|
|
||||||
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
env := jobEnv{
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
// The results service is the cache server's too, which is what the runner advertises.
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755))
|
||||||
content := []byte("cached through the patched gate")
|
content := []byte("cached through the patched gate")
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
|
||||||
|
|
||||||
const key = "patched-gate-key"
|
const key = "patched-gate-key"
|
||||||
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
inputs := map[string]string{"path": "to-cache", "key": key}
|
||||||
|
|
||||||
|
missed := runActionEntrypoint(t, restore, env, inputs)
|
||||||
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
||||||
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
||||||
|
|
||||||
saved := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
saved := runActionEntrypoint(t, save, env, inputs)
|
||||||
require.Contains(t, saved, "Cache saved with key: "+key)
|
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||||
|
|
||||||
restored := tempDirPath(t)
|
env.workspace = tempDirPath(t)
|
||||||
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
hit := runActionEntrypoint(t, restore, env, inputs)
|
||||||
require.Contains(t, hit, "Cache restored from key: "+key)
|
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, got)
|
assert.Equal(t, content, got)
|
||||||
|
|
||||||
|
// Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the
|
||||||
|
// cache server on its own address. That is what a runner without a results service of its own
|
||||||
|
// leaves its jobs with, so it has to round trip too.
|
||||||
|
env.workspace = tempDirPath(t)
|
||||||
|
v1 := runActionEntrypoint(t, filepath.Join(sidecarDir(filepath.Dir(restore)), "index.js"), env, inputs)
|
||||||
|
require.Contains(t, v1, "Cache service version: v1")
|
||||||
|
require.Contains(t, v1, "Cache restored from key: "+key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||||
@@ -158,19 +195,32 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|||||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||||
// the two shapes and leaving every cache on v1.
|
// the two shapes and leaving every cache on v1.
|
||||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||||
for _, tc := range []struct{ repo, ref, path string }{
|
for _, tc := range []struct {
|
||||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
repo, ref, path string
|
||||||
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
wantPatched bool
|
||||||
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
}{
|
||||||
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
|
||||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
// actions that drive a Go or a Rust cache client of their own.
|
||||||
|
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
||||||
|
{"actions/setup-node", "v7.0.0", "dist/cache-save/index.js", true},
|
||||||
|
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
||||||
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
||||||
|
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
||||||
|
{"ruby/setup-ruby", "v1.321.0", "dist/index.js", true},
|
||||||
|
{"pnpm/action-setup", "v6.0.9", "dist/index.js", true},
|
||||||
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
||||||
|
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
||||||
|
{"docker/build-push-action", "v7.3.0", "dist/index.cjs", true},
|
||||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||||
// releases before it refuse in a shape the runner leaves alone.
|
// releases before it refuse in a shape the runner leaves alone.
|
||||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js"},
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
||||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js", true},
|
||||||
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
{"actions/download-artifact", "v8.0.1", "dist/index.js", true},
|
||||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
|
||||||
|
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
|
||||||
|
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
||||||
|
{"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -179,7 +229,11 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
out, patched := patchedBundle(data)
|
out, patched := patchedBundle(data)
|
||||||
assert.True(t, patched, "the version gate was not patched")
|
require.Equal(t, tc.wantPatched, patched)
|
||||||
|
if !tc.wantPatched {
|
||||||
|
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
||||||
|
return
|
||||||
|
}
|
||||||
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
||||||
|
|
||||||
if !strings.Contains(string(data), CacheServiceV2Env) {
|
if !strings.Contains(string(data), CacheServiceV2Env) {
|
||||||
@@ -194,3 +248,121 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk
|
||||||
|
// to the results service, which is this runner's cache server forwarding the artifact half on to
|
||||||
|
// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once:
|
||||||
|
// the patch, and the forwarding the job's registration set up.
|
||||||
|
func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
var called []string
|
||||||
|
var zipped []byte
|
||||||
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method := path.Base(r.URL.Path)
|
||||||
|
called = append(called, method)
|
||||||
|
w.Header().Set("x-ms-request-id", "stub")
|
||||||
|
switch method {
|
||||||
|
case "CreateArtifact":
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"signed_upload_url":"http://`+r.Host+
|
||||||
|
`/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"}`)
|
||||||
|
case "FinalizeArtifact":
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"artifact_id":"1"}`)
|
||||||
|
case "ListArtifacts":
|
||||||
|
_, _ = io.WriteString(w, `{"artifacts":[{"workflow_run_backend_id":"11",`+
|
||||||
|
`"workflow_job_run_backend_id":"22","database_id":"1","name":"an-artifact","size":"`+
|
||||||
|
strconv.Itoa(len(zipped))+`"}]}`)
|
||||||
|
case "GetSignedArtifactURL":
|
||||||
|
_, _ = io.WriteString(w, `{"signed_url":"http://`+r.Host+`/download"}`)
|
||||||
|
case "download":
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
_, _ = w.Write(zipped)
|
||||||
|
default: // the zip on its way up, in the blocks the Azure protocol puts it in
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
switch r.URL.Query().Get("comp") {
|
||||||
|
case "block":
|
||||||
|
zipped = append(zipped, body...)
|
||||||
|
case "blocklist": // the ordering document, not content
|
||||||
|
default:
|
||||||
|
zipped = body
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer gitea.Close()
|
||||||
|
|
||||||
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
// The artifact client decodes the runtime token for the run ids it puts in its requests, where
|
||||||
|
// the cache client only presents it, so this one has to be shaped like Gitea's.
|
||||||
|
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
|
||||||
|
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
|
||||||
|
|
||||||
|
upload := patchedAction(t, "actions/upload-artifact", "v7.0.1", "dist/upload/index.js")
|
||||||
|
|
||||||
|
env := jobEnv{
|
||||||
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
uploaded := []byte("through the results service")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600))
|
||||||
|
|
||||||
|
out := runActionEntrypoint(t, upload, env, map[string]string{
|
||||||
|
"name": "an-artifact", "path": "artifact.txt", "if-no-files-found": "error",
|
||||||
|
"retention-days": "0", "compression-level": "6", "overwrite": "false",
|
||||||
|
"include-hidden-files": "false", "archive": "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "has been successfully uploaded")
|
||||||
|
|
||||||
|
// And back down again: listing and downloading go the same way, and the signed URL the
|
||||||
|
// artifact service hands out is fetched straight from it.
|
||||||
|
download := patchedAction(t, "actions/download-artifact", "v8.0.1", "dist/index.js")
|
||||||
|
env.workspace = tempDirPath(t)
|
||||||
|
out = runActionEntrypoint(t, download, env, map[string]string{
|
||||||
|
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
||||||
|
"skip-decompress": "false", "include-hidden-files": "false", "github-token": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Artifact download completed")
|
||||||
|
assert.Subset(t, called,
|
||||||
|
[]string{"CreateArtifact", "UploadArtifact", "FinalizeArtifact", "ListArtifacts", "GetSignedArtifactURL"},
|
||||||
|
"the artifact service was not reached through the cache server")
|
||||||
|
got, err := os.ReadFile(filepath.Join(env.workspace, "downloaded", "artifact.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uploaded, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The setup actions carry the same toolkit and reach the same service, from a key of their own
|
||||||
|
// making. setup-node is the cheapest of them to run: given a lockfile and no version to install,
|
||||||
|
// it does the cache lookup and nothing else.
|
||||||
|
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
||||||
|
requireHostTools(t, "node", "npm")
|
||||||
|
|
||||||
|
setup := patchedAction(t, "actions/setup-node", "v7.0.0", "dist/setup/index.js")
|
||||||
|
|
||||||
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
const token = "setup-runtime-token"
|
||||||
|
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})()
|
||||||
|
|
||||||
|
env := jobEnv{
|
||||||
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"),
|
||||||
|
[]byte(`{"lockfileVersion":3}`), 0o600))
|
||||||
|
|
||||||
|
out := runActionEntrypoint(t, setup, env, map[string]string{"cache": "npm"})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Cache service version: v2")
|
||||||
|
require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server")
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -283,7 +282,7 @@ func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
|||||||
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
||||||
// that fails gets them back. The action's path inside its repository is part of where they live.
|
// that fails gets them back. The action's path inside its repository is part of where they live.
|
||||||
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
||||||
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
newStep := func(t *testing.T, patch bool) (*stepActionRemote, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
@@ -291,8 +290,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Env: env,
|
Config: &Config{ActionCacheDir: t.TempDir(), PatchToolkit: patch},
|
||||||
Config: &Config{ActionCacheDir: t.TempDir()},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||||
@@ -301,8 +299,8 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
return sar, script
|
return sar, script
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
t.Run("left alone when the runner does not patch", func(t *testing.T) {
|
||||||
sar, script := newStep(t, map[string]string{})
|
sar, script := newStep(t, false)
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
@@ -311,7 +309,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
||||||
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
sar, script := newStep(t, true)
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Each example persists **two** things, and it is worth knowing which is which:
|
|||||||
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
||||||
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
||||||
|
|
||||||
|
- Kubernetes SIGKILLs a pod 30s after SIGTERM by default, long before a job finishes and reports its result, which leaves tasks the server can only reap as zombies. The manifests raise `terminationGracePeriodSeconds` to three hours, matching the systemd example and the `runner.timeout` job ceiling; set `runner.shutdown_timeout` below that so the runner drains jobs within the window rather than being killed mid-cleanup.
|
||||||
|
|
||||||
Files in this directory:
|
Files in this directory:
|
||||||
|
|
||||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
- [`dind-docker.yaml`](dind-docker.yaml)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ spec:
|
|||||||
app: runner
|
app: runner
|
||||||
spec:
|
spec:
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
|
terminationGracePeriodSeconds: 10800 # keep above runner.shutdown_timeout, see README
|
||||||
volumes:
|
volumes:
|
||||||
- name: docker-socket
|
- name: docker-socket
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package kubernetes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var gracePeriod = regexp.MustCompile(`terminationGracePeriodSeconds: (\d+)`)
|
||||||
|
|
||||||
|
// Without it Kubernetes SIGKILLs the pod 30s after SIGTERM, mid-job.
|
||||||
|
func TestManifestsSetTerminationGracePeriod(t *testing.T) {
|
||||||
|
files, err := filepath.Glob("*.yaml")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, files)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if !strings.Contains(string(content), "containers:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := gracePeriod.FindStringSubmatch(string(content))
|
||||||
|
require.NotNil(t, match, file)
|
||||||
|
seconds, err := strconv.Atoi(match[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, seconds, 3600, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user