mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
16 Commits
fc0e03e5a9
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d47fb5ed | ||
|
|
34bfa19150 | ||
|
|
96d9f491db | ||
|
|
14ec00b66e | ||
|
|
68c6a5b4f1 | ||
|
|
0cd0e52a24 | ||
|
|
47d5b5ad03 | ||
|
|
2398d4a527 | ||
|
|
b7a3bf98bc | ||
|
|
da4037899a | ||
|
|
e4fe49dba4 | ||
|
|
41c72216bf | ||
|
|
3f7fd16ea1 | ||
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 |
@@ -33,6 +33,8 @@ jobs:
|
|||||||
done
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
|
- name: checks
|
||||||
|
run: make checks
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.env
|
.env
|
||||||
!/act/runner/testdata/secrets/.env
|
!/act/runner/testdata/secrets/.env
|
||||||
.runner
|
.runner
|
||||||
|
.runner.lock
|
||||||
coverage.txt
|
coverage.txt
|
||||||
.tmp/
|
.tmp/
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ linters:
|
|||||||
- dupl
|
- dupl
|
||||||
- errcheck
|
- errcheck
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
- forcetypeassert
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
- gocritic
|
- gocritic
|
||||||
- goheader
|
- goheader
|
||||||
@@ -102,6 +103,9 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
path: cmd
|
path: cmd
|
||||||
|
- linters:
|
||||||
|
- forcetypeassert
|
||||||
|
path: _test\.go
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
21
Makefile
21
Makefile
@@ -4,9 +4,9 @@ DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
|||||||
GO ?= go
|
GO ?= go
|
||||||
SHASUM ?= shasum -a 256
|
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@latest
|
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.10
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # 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
|
||||||
@@ -18,8 +18,10 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
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
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||||
|
|
||||||
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
STATIC ?=
|
STATIC ?=
|
||||||
EXTLDFLAGS ?=
|
EXTLDFLAGS ?=
|
||||||
@@ -95,7 +97,7 @@ go-check:
|
|||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: fmt
|
||||||
@diff=$$(git diff --color=always); \
|
@diff=$$(git diff --color=always -- '*.go'); \
|
||||||
if [ -n "$$diff" ]; then \
|
if [ -n "$$diff" ]; then \
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
echo "Please run 'make fmt' and commit the result:"; \
|
||||||
printf "%s" "$${diff}"; \
|
printf "%s" "$${diff}"; \
|
||||||
@@ -110,6 +112,9 @@ deps-tools: ## install tool dependencies
|
|||||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
.PHONY: checks
|
||||||
|
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-go-windows ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
@@ -131,7 +136,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|||||||
@node ./tools/lint-pr-title.ts
|
@node ./tools/lint-pr-title.ts
|
||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check: deps-tools
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
@@ -148,8 +153,8 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: coverage-report
|
.PHONY: coverage-report
|
||||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -209,10 +209,50 @@ 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.
|
||||||
|
|
||||||
|
#### Proxy
|
||||||
|
|
||||||
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
http_proxy=http://proxy.example:3128
|
||||||
|
https_proxy=http://proxy.example:3128
|
||||||
|
no_proxy=gitea.internal,.example.local
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner uses them for its own requests and gives them to every job, in lower and upper case.
|
||||||
|
|
||||||
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
|
- the cache server
|
||||||
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
|
- the job's service containers
|
||||||
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
|
|
||||||
|
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||||
|
|
||||||
|
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||||
|
|
||||||
|
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||||
|
|
||||||
|
Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network.
|
||||||
|
|
||||||
|
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||||
|
|
||||||
|
**Cache service v2**
|
||||||
|
|
||||||
|
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
v2: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
**Shared cache across multiple runners**
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||||
@@ -254,11 +294,12 @@ Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image en
|
|||||||
|
|
||||||
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
||||||
|
|
||||||
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
|
While the runner is idle it cleans up after earlier jobs:
|
||||||
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
|
|
||||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||||
|
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||||
|
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||||
|
|
||||||
#### Post-task script (`runner.post_task_script`)
|
#### Post-task script (`runner.post_task_script`)
|
||||||
|
|
||||||
@@ -270,6 +311,16 @@ On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not su
|
|||||||
|
|
||||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||||
|
|
||||||
|
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||||
|
|
||||||
|
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||||
|
|
||||||
|
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||||
|
|
||||||
|
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||||
|
|
||||||
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type Handler struct {
|
|||||||
storage *Storage
|
storage *Storage
|
||||||
router *httprouter.Router
|
router *httprouter.Router
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
|
port int
|
||||||
server *http.Server
|
server *http.Server
|
||||||
logger logrus.FieldLogger
|
logger logrus.FieldLogger
|
||||||
|
|
||||||
@@ -157,12 +158,13 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||||
// does not attach Authorization when downloading archiveLocation.
|
// does not attach Authorization when downloading archiveLocation.
|
||||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
||||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||||
// cache API can authenticate them. Always wired so the routes exist; the
|
// cache API can authenticate them. Always wired so the routes exist; the
|
||||||
// handlers themselves 401 when internalSecret is unset.
|
// handlers themselves 401 when internalSecret is unset.
|
||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
router.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.router = router
|
h.router = router
|
||||||
|
|
||||||
@@ -177,6 +179,12 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
listener.Close()
|
||||||
|
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||||
|
}
|
||||||
|
h.port = addr.Port
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -194,9 +202,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
|
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
// TODO: make the external url configurable if necessary
|
// TODO: make the external url configurable if necessary
|
||||||
return fmt.Sprintf("http://%s:%d",
|
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||||
h.outboundIP,
|
|
||||||
h.listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||||
@@ -334,7 +340,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findCache(db, cred.Repo, keys, version)
|
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
@@ -343,15 +349,6 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
h.responseJSON(w, r, 204)
|
h.responseJSON(w, r, 204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, err := h.storage.Exist(cache.ID); err != nil {
|
|
||||||
h.responseJSON(w, r, 500, err)
|
|
||||||
return
|
|
||||||
} else if !ok {
|
|
||||||
_ = db.Delete(cache.ID, cache)
|
|
||||||
h.responseJSON(w, r, 204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
@@ -359,6 +356,25 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
||||||
|
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
||||||
|
// entry is dropped on the way out.
|
||||||
|
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||||
|
cache, err := findCache(db, repo, keys, version)
|
||||||
|
if err != nil || cache == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ok, err := h.storage.Exist(cache.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
_ = db.Delete(cache.ID, cache)
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// POST /_apis/artifactcache/caches
|
||||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
@@ -433,7 +449,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.useCache(id)
|
_ = h.touchCache(uint64(id), false)
|
||||||
h.responseJSON(w, r, 200)
|
h.responseJSON(w, r, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,23 +490,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
|
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
size, err := h.storage.Commit(cache.ID, cache.Size)
|
if err := h.commitCache(cache); err != nil {
|
||||||
if err != nil {
|
|
||||||
h.responseJSON(w, r, 500, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
|
||||||
cache.Size = size
|
|
||||||
|
|
||||||
db, err = h.openDB()
|
|
||||||
if err != nil {
|
|
||||||
h.responseJSON(w, r, 500, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
cache.Complete = true
|
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -498,8 +498,28 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
h.responseJSON(w, r, 200)
|
h.responseJSON(w, r, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
|
||||||
|
// have closed its store first: Commit concatenates the whole archive and would otherwise
|
||||||
|
// hold bolt's exclusive file lock for the duration.
|
||||||
|
func (h *Handler) commitCache(cache *Cache) error {
|
||||||
|
written, err := h.storage.Commit(cache.ID, cache.Size)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 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.Complete = true
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.Update(cache.ID, cache)
|
||||||
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/artifacts/:id
|
// GET /_apis/artifactcache/artifacts/:id
|
||||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
// Authenticated via signed URL (see signedAuth), not bearer, because the
|
||||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||||
// Repository scoping is already enforced at find() time; the signature binds
|
// Repository scoping is already enforced at find() time; the signature binds
|
||||||
// the URL to the specific cache ID and an expiry.
|
// the URL to the specific cache ID and an expiry.
|
||||||
@@ -509,7 +529,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.useCache(id)
|
_ = h.touchCache(uint64(id), false)
|
||||||
h.storage.Serve(w, r, uint64(id))
|
h.storage.Serve(w, r, uint64(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +563,9 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
|
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
@@ -566,7 +588,7 @@ func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expected := h.computeSignature(id, exp)
|
expected := h.computeSignature(purpose, id, exp)
|
||||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||||
return
|
return
|
||||||
@@ -650,19 +672,26 @@ func credFromContext(ctx context.Context) JobCredential {
|
|||||||
return JobCredential{}
|
return JobCredential{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
|
||||||
|
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
|
||||||
|
// other way round. Downloads use the empty purpose, the message v1 has always signed.
|
||||||
|
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
||||||
mac := hmac.New(sha256.New, h.secret)
|
mac := hmac.New(sha256.New, h.secret)
|
||||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
|
||||||
return hex.EncodeToString(mac.Sum(nil))
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
// 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 {
|
||||||
expUnix := exp.Unix()
|
expUnix := exp.Unix()
|
||||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||||
q.Set("sig", sig)
|
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||||
|
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// if not found, return (nil, nil) instead of an error.
|
||||||
@@ -670,16 +699,12 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
for _, prefix := range keys {
|
for _, prefix := range keys {
|
||||||
// if a key in the list matches exactly, don't return partial matches
|
// if a key in the list matches exactly, don't return partial matches
|
||||||
if err := db.FindOne(cache,
|
exact, err := findExactCache(db, repo, prefix, version, true)
|
||||||
bolthold.Where("Repo").Eq(repo).
|
|
||||||
And("Key").Eq(prefix).
|
|
||||||
And("Version").Eq(version).
|
|
||||||
And("Complete").Eq(true).
|
|
||||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find cache: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return cache, nil
|
if exact != nil {
|
||||||
|
return exact, nil
|
||||||
}
|
}
|
||||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||||
re, err := regexp.Compile(prefixPattern)
|
re, err := regexp.Compile(prefixPattern)
|
||||||
@@ -702,6 +727,34 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
|
||||||
|
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
|
||||||
|
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
|
||||||
|
//
|
||||||
|
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
||||||
|
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||||
|
// touches UsedAt on every part.
|
||||||
|
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
|
||||||
|
sortBy := "UsedAt"
|
||||||
|
if complete {
|
||||||
|
sortBy = "CreatedAt"
|
||||||
|
}
|
||||||
|
cache := &Cache{}
|
||||||
|
err := db.FindOne(cache,
|
||||||
|
bolthold.Where("Repo").Eq(repo).
|
||||||
|
And("Key").Eq(key).
|
||||||
|
And("Version").Eq(version).
|
||||||
|
And("Complete").Eq(complete).
|
||||||
|
SortBy(sortBy).Reverse())
|
||||||
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||||
return fmt.Errorf("insert cache: %w", err)
|
return fmt.Errorf("insert cache: %w", err)
|
||||||
@@ -713,18 +766,30 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) useCache(id int64) {
|
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||||
|
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
|
||||||
|
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
|
||||||
|
// other jobs restore no longer matching its recorded size. An entry missing from the store is
|
||||||
|
// accepted, since the signature proves the id was handed out.
|
||||||
|
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
if err := db.Get(id, cache); err != nil {
|
if err := db.Get(id, cache); err != nil {
|
||||||
return
|
if errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if requireIncomplete && cache.Complete {
|
||||||
|
return fmt.Errorf("cache %d: already complete", id)
|
||||||
}
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
_ = db.Update(cache.ID, cache)
|
return db.Update(cache.ID, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -445,13 +445,6 @@ func TestHandler(t *testing.T) {
|
|||||||
require.Equal(t, 404, resp.StatusCode)
|
require.Equal(t, 404, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("get with not exist id", func(t *testing.T) {
|
|
||||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 404, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("get with multiple keys", func(t *testing.T) {
|
t.Run("get with multiple keys", func(t *testing.T) {
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
@@ -469,7 +462,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -554,7 +548,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -607,7 +602,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -646,6 +642,20 @@ func TestHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backdateCache rewrites a cache's CreatedAt. It has one-second resolution, so age-ordering
|
||||||
|
// tests set it directly instead of sleeping a second between uploads.
|
||||||
|
func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration) {
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var caches []*Cache
|
||||||
|
require.NoError(t, db.Find(&caches, bolthold.Where("Key").Eq(key)))
|
||||||
|
require.Len(t, caches, 1)
|
||||||
|
caches[0].CreatedAt = time.Now().Add(-age).Unix()
|
||||||
|
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) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
var id uint64
|
var id uint64
|
||||||
{
|
{
|
||||||
@@ -1031,14 +1041,14 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
|||||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||||
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(dir, "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer second.Close()
|
defer second.Close()
|
||||||
|
|
||||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
assert.Equal(t, sig, second.computeSignature("", 42, exp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
||||||
|
|||||||
268
act/artifactcache/handler_v2.go
Normal file
268
act/artifactcache/handler_v2.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The cache service v2 API. A client on this version talks twirp to
|
||||||
|
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||||
|
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||||
|
// Both API versions are served from the same store, so a repository keeps its cache
|
||||||
|
// when a workflow moves between action versions.
|
||||||
|
const (
|
||||||
|
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||||
|
|
||||||
|
// blobPath authenticates by signature, because the client uploads without an
|
||||||
|
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||||
|
blobPath = apiPath + "/blobs"
|
||||||
|
|
||||||
|
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||||
|
blobUploadPurpose = "upload:"
|
||||||
|
|
||||||
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
|
// twirpInternal is the only error code that is not the client's fault.
|
||||||
|
twirpInternal = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
|
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||||
|
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||||
|
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||||
|
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||||
|
// the upload.
|
||||||
|
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Key == "" || req.Version == "" {
|
||||||
|
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||||
|
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||||
|
// existing and silently never saved.
|
||||||
|
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if existing != nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
cache := &Cache{
|
||||||
|
Repo: cred.Repo,
|
||||||
|
Key: req.Key,
|
||||||
|
Version: req.Version,
|
||||||
|
Size: -1, // the size is only known at finalize time
|
||||||
|
CreatedAt: now,
|
||||||
|
UsedAt: now,
|
||||||
|
}
|
||||||
|
if err := insertCache(db, cache); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache == nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Close() // commitCache needs the store closed
|
||||||
|
|
||||||
|
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||||
|
if err := h.commitCache(cache); err != nil {
|
||||||
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
// int64 fields travel as strings in the proto JSON mapping.
|
||||||
|
"entryId": strconv.FormatUint(cache.ID, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
cred := credFromContext(r.Context())
|
||||||
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, "malformed_request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||||
|
if err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache == nil {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
|
"matchedKey": cache.Key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||||
|
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||||
|
// in order.
|
||||||
|
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.touchCache(id, true); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
switch strings.ToLower(query.Get("comp")) {
|
||||||
|
case "block":
|
||||||
|
blockID := query.Get("blockid")
|
||||||
|
if blockID == "" {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||||
|
case "blocklist":
|
||||||
|
var list struct{ Latest []string }
|
||||||
|
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.storage.OrderBlocks(id, list.Latest)
|
||||||
|
default:
|
||||||
|
err = h.storage.Write(id, 0, r.Body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||||
|
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||||
|
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||||
|
// instead of a parse error.
|
||||||
|
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||||
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if code == twirpInternal {
|
||||||
|
status = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||||
|
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||||
|
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||||
|
type (
|
||||||
|
v2CreateRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
v2FinalizeRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SizeBytes json.Number `json:"size_bytes"`
|
||||||
|
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
v2DownloadRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
RestoreKeys []string `json:"restore_keys"`
|
||||||
|
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d v2DownloadRequest) keys() []string {
|
||||||
|
restoreKeys := d.RestoreKeys
|
||||||
|
if len(restoreKeys) == 0 {
|
||||||
|
restoreKeys = d.RestoreKeysCamel
|
||||||
|
}
|
||||||
|
return append([]string{d.Key}, restoreKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||||
|
var req T
|
||||||
|
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
236
act/artifactcache/handler_v2_test.go
Normal file
236
act/artifactcache/handler_v2_test.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v2Call posts a twirp request to the cache service and returns the decoded response.
|
||||||
|
// Field names are the proto ones, which is what the toolkit's client sends.
|
||||||
|
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
got := map[string]any{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
|
||||||
|
func putBlob(t *testing.T, url string, content []byte) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func getURL(t *testing.T, url string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// with the upload URL it used.
|
||||||
|
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||||
|
require.Equal(t, true, created["ok"])
|
||||||
|
uploadURL, _ = created["signedUploadUrl"].(string)
|
||||||
|
require.NotEmpty(t, uploadURL)
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||||
|
|
||||||
|
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": key, "version": version,
|
||||||
|
"size_bytes": strconv.Itoa(len(content)),
|
||||||
|
}), uploadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
|
||||||
|
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||||
|
// or to replace a finalized entry.
|
||||||
|
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||||
|
|
||||||
|
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
assert.NotEmpty(t, finalized["entryId"])
|
||||||
|
|
||||||
|
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||||
|
// and it is an upload URL only: nothing reads a blob back through it.
|
||||||
|
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
|
||||||
|
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "deps-v1", got["matchedKey"])
|
||||||
|
downloadURL, _ := got["signedDownloadUrl"].(string)
|
||||||
|
require.NotEmpty(t, downloadURL)
|
||||||
|
assert.Equal(t, content, getURL(t, downloadURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||||
|
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||||
|
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
|
uploadURL, _ := created["signedUploadUrl"].(string)
|
||||||
|
require.NotEmpty(t, uploadURL)
|
||||||
|
|
||||||
|
blocks := map[string][]byte{}
|
||||||
|
var order []string
|
||||||
|
for i, part := range []string{"hello ", "world", "!"} {
|
||||||
|
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||||
|
blocks[blockID] = []byte(part)
|
||||||
|
order = append(order, blockID)
|
||||||
|
}
|
||||||
|
// Upload in an order that is not the block list order.
|
||||||
|
for _, blockID := range []string{order[2], order[0], order[1]} {
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var list bytes.Buffer
|
||||||
|
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range order {
|
||||||
|
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
list.WriteString(`</BlockList>`)
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
|
||||||
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
|
})
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
|
handler := startTestHandler(t)
|
||||||
|
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||||
|
require.Equal(t, true, saved["ok"])
|
||||||
|
|
||||||
|
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The toolkit serialises with the proto field names; the camelCase spellings of the same
|
||||||
|
// proto JSON mapping are accepted alongside them.
|
||||||
|
for _, field := range []string{"restore_keys", "restoreKeys"} {
|
||||||
|
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||||
|
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||||
|
})
|
||||||
|
require.Equal(t, true, got["ok"])
|
||||||
|
assert.Equal(t, "deps-abc", got["matchedKey"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||||
|
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, false, again["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// A key that is only a prefix of an existing one is a different entry, so the
|
||||||
|
// reservation check must be exact and not a restore-key prefix match, or the shorter
|
||||||
|
// key would be reported as existing and silently never saved.
|
||||||
|
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||||
|
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||||
|
})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The size the client declares is what Commit validates the assembled archive against.
|
||||||
|
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||||
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
||||||
|
|
||||||
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||||
|
})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both API versions are served from one store, so an entry written through v2 is a hit for
|
||||||
|
// a v1 client asking for the same key and version.
|
||||||
|
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
|
||||||
|
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
got := map[string]any{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||||
|
assert.NotEmpty(t, got["archiveLocation"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||||
|
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||||
|
handler.RegisterJob("other-runtime-token", "other/repo")
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||||
|
|
||||||
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, false, got["ok"])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||||
name := s.tempName(id, offset)
|
return s.writeFile(s.tempName(id, offset), reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||||
|
return s.writeFile(s.blockName(id, blockID), reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||||
|
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||||
|
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||||
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
for i, blockID := range blockIDs {
|
||||||
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = os.RemoveAll(s.tempDir(id))
|
_ = os.RemoveAll(s.tempDir(id))
|
||||||
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
|||||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
written, err := assemble(name, tempNames)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// If size is less than 0, it means the size is unknown.
|
||||||
|
// We can't check the size of the file, just skip the check.
|
||||||
|
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||||
|
if size >= 0 && written != size {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||||
|
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||||
|
func assemble(name string, tempNames []string) (int64, error) {
|
||||||
|
if len(tempNames) == 1 {
|
||||||
|
info, err := os.Stat(tempNames[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Size(), os.Rename(tempNames[0], name)
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.Create(name)
|
file, err := os.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
|||||||
}
|
}
|
||||||
written += n
|
written += n
|
||||||
}
|
}
|
||||||
|
|
||||||
// If size is less than 0, it means the size is unknown.
|
|
||||||
// We can't check the size of the file, just skip the check.
|
|
||||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
|
||||||
if size >= 0 && written != size {
|
|
||||||
_ = file.Close()
|
|
||||||
_ = os.Remove(name)
|
|
||||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
|||||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||||
|
// Commit's name-ordered concatenation.
|
||||||
|
const blockFilePrefix = "block-"
|
||||||
|
|
||||||
|
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||||
|
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||||
|
// path element.
|
||||||
|
sum := sha256.Sum256([]byte(blockID))
|
||||||
|
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||||
dir := s.tempDir(id)
|
dir := s.tempDir(id)
|
||||||
files, err := os.ReadDir(dir)
|
files, err := os.ReadDir(dir)
|
||||||
@@ -127,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
|||||||
}
|
}
|
||||||
var names []string
|
var names []string
|
||||||
for _, v := range files {
|
for _, v := range files {
|
||||||
if !v.IsDir() {
|
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||||
names = append(names, filepath.Join(dir, v.Name()))
|
names = append(names, filepath.Join(dir, v.Name()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import "slices"
|
|
||||||
|
|
||||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
|
||||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
|
||||||
listNames := make([]string, 0)
|
|
||||||
lists := make([][]any, 0)
|
|
||||||
for k, v := range mapOfLists {
|
|
||||||
listNames = append(listNames, k)
|
|
||||||
lists = append(lists, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
listCart := cartN(lists...)
|
|
||||||
|
|
||||||
rtn := make([]map[string]any, 0)
|
|
||||||
for _, list := range listCart {
|
|
||||||
vMap := make(map[string]any)
|
|
||||||
for i, v := range list {
|
|
||||||
vMap[listNames[i]] = v
|
|
||||||
}
|
|
||||||
rtn = append(rtn, vMap)
|
|
||||||
}
|
|
||||||
return rtn
|
|
||||||
}
|
|
||||||
|
|
||||||
func cartN(a ...[]any) [][]any {
|
|
||||||
c := 1
|
|
||||||
for _, a := range a {
|
|
||||||
c *= len(a)
|
|
||||||
}
|
|
||||||
if c == 0 || len(a) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p := make([][]any, c)
|
|
||||||
b := make([]any, c*len(a))
|
|
||||||
n := make([]int, len(a))
|
|
||||||
s := 0
|
|
||||||
for i := range p {
|
|
||||||
e := s + len(a)
|
|
||||||
pi := b[s:e]
|
|
||||||
p[i] = pi
|
|
||||||
s = e
|
|
||||||
for j, n := range n {
|
|
||||||
pi[j] = a[j][n]
|
|
||||||
}
|
|
||||||
for j := range slices.Backward(n) {
|
|
||||||
n[j]++
|
|
||||||
if n[j] < len(a[j]) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
n[j] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCartesianProduct(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
input := map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {"a", "b", "c"},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := CartesianProduct(input)
|
|
||||||
assert.Len(output, 24)
|
|
||||||
|
|
||||||
for _, v := range output {
|
|
||||||
assert.Len(v, 3)
|
|
||||||
|
|
||||||
assert.Contains(v, "foo")
|
|
||||||
assert.Contains(v, "bar")
|
|
||||||
assert.Contains(v, "baz")
|
|
||||||
}
|
|
||||||
|
|
||||||
input = map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
|
|
||||||
input = map[string][]any{}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
|
||||||
func TestMaxParallel2Quick(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxSimultaneous atomic.Int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 4)
|
|
||||||
for i := range 4 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Update max if needed
|
|
||||||
for {
|
|
||||||
maxValue := maxSimultaneous.Load()
|
|
||||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := NewParallelExecutor(2, executors...)(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
|
||||||
"Should not exceed max-parallel: 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that verifies max-parallel: 1 enforces sequential execution
|
|
||||||
func TestMaxParallel1Sequential(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxSimultaneous atomic.Int32
|
|
||||||
var executionOrder []int
|
|
||||||
var orderMutex sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Track execution order
|
|
||||||
orderMutex.Lock()
|
|
||||||
executionOrder = append(executionOrder, taskID)
|
|
||||||
orderMutex.Unlock()
|
|
||||||
|
|
||||||
// Update max if needed
|
|
||||||
for {
|
|
||||||
maxValue := maxSimultaneous.Load()
|
|
||||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := NewParallelExecutor(1, executors...)(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
|
||||||
"max-parallel: 1 should only run 1 task at a time")
|
|
||||||
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
|
||||||
func TestMaxParallelJobExecution(t *testing.T) {
|
|
||||||
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxConcurrent int32
|
|
||||||
var executionOrder []int
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Track max concurrent
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
executionOrder = append(executionOrder, taskID)
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(1, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
|
||||||
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxConcurrent int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 10)
|
|
||||||
for i := range 10 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(3, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
|
||||||
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
|
||||||
var maxConcurrent int32
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
// When maxParallel is 0 or negative, it defaults to 1
|
|
||||||
err := NewParallelExecutor(0, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelWithErrors tests error handling with max-parallel
|
|
||||||
func TestMaxParallelWithErrors(t *testing.T) {
|
|
||||||
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
|
||||||
var successCount int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
if taskID == 2 {
|
|
||||||
return assert.AnError
|
|
||||||
}
|
|
||||||
atomic.AddInt32(&successCount, 1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(2, executors...)(ctx)
|
|
||||||
|
|
||||||
// Should return the error from task 2
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
// Other tasks should still execute
|
|
||||||
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ContextCancellation", func(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
var startedCount int32
|
|
||||||
executors := make([]Executor, 10)
|
|
||||||
for i := range 10 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
atomic.AddInt32(&startedCount, 1)
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel after a short delay
|
|
||||||
go func() {
|
|
||||||
time.Sleep(30 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := NewParallelExecutor(3, executors...)(ctx)
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
// Not all tasks should start due to cancellation (but timing may vary)
|
|
||||||
// Just verify cancellation occurred
|
|
||||||
t.Logf("Started %d tasks before cancellation", startedCount)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
|
||||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
|
||||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
|
||||||
var sharedCounter int
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 100)
|
|
||||||
for i := range 100 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
mu.Lock()
|
|
||||||
sharedCounter++
|
|
||||||
mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(10, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ChannelCommunication", func(t *testing.T) {
|
|
||||||
resultChan := make(chan int, 50)
|
|
||||||
|
|
||||||
executors := make([]Executor, 50)
|
|
||||||
for i := range 50 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
resultChan <- taskID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(5, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
close(resultChan)
|
|
||||||
|
|
||||||
results := make(map[int]bool)
|
|
||||||
for result := range resultChan {
|
|
||||||
results[result] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, results, 50, "All task IDs should be received")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -82,44 +82,45 @@ func TestNewConditionalExecutor(t *testing.T) {
|
|||||||
assert.Equal(1, falseCount)
|
assert.Equal(1, falseCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutor(t *testing.T) {
|
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||||
assert := assert.New(t)
|
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||||
|
// find the gate already open so the last one still finishes with no partner left.
|
||||||
|
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
|
||||||
|
var counted, active, peak atomic.Int32
|
||||||
|
var once sync.Once
|
||||||
|
reached := make(chan struct{})
|
||||||
|
|
||||||
ctx := context.Background()
|
return func(ctx context.Context) error {
|
||||||
|
counted.Add(1)
|
||||||
var count, activeCount, maxCount atomic.Int32
|
running := active.Add(1)
|
||||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
|
||||||
count.Add(1)
|
|
||||||
|
|
||||||
active := activeCount.Add(1)
|
|
||||||
for {
|
for {
|
||||||
m := maxCount.Load()
|
seen := peak.Load()
|
||||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
if running >= wantActive {
|
||||||
activeCount.Add(-1)
|
once.Do(func() { close(reached) })
|
||||||
|
}
|
||||||
|
<-reached
|
||||||
|
active.Add(-1)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, &counted, &peak
|
||||||
|
}
|
||||||
|
|
||||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||||
|
|
||||||
// Reset to test running the executor with 0 parallelism
|
// parallelism below 1 falls back to a single worker
|
||||||
count.Store(0)
|
exec, count, maxActive = concurrencyProbe(1)
|
||||||
activeCount.Store(0)
|
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||||
maxCount.Store(0)
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
|
||||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
|
||||||
|
|
||||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
|
||||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
|
||||||
assert.NoError(errSingle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
@@ -173,6 +174,23 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
|||||||
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
||||||
|
var successCount atomic.Int32
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range executors {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
if i == 2 {
|
||||||
|
return errors.New("fake error")
|
||||||
|
}
|
||||||
|
successCount.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
||||||
|
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var calls []string
|
var calls []string
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"github.com/go-git/go-git/v5/config"
|
||||||
@@ -32,7 +33,7 @@ var (
|
|||||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||||
|
|
||||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||||
|
|
||||||
ErrShortRef = errors.New("short SHA references are not supported")
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
ErrNoRepo = errors.New("unable to find git repo")
|
ErrNoRepo = errors.New("unable to find git repo")
|
||||||
@@ -43,10 +44,7 @@ var (
|
|||||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||||
func AcquireCloneLock(dir string) func() {
|
func AcquireCloneLock(dir string) func() {
|
||||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
return cloneLocks.Lock(dir)
|
||||||
mu := v.(*sync.Mutex)
|
|
||||||
mu.Lock()
|
|
||||||
return mu.Unlock
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -610,12 +609,4 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
t.Fatal("acquire on a different directory must not block")
|
t.Fatal("acquire on a different directory must not block")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
||||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
||||||
require.Same(t, v1, v2)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ func GetOutboundIP() net.IP {
|
|||||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||||
|
return addr.IP
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||||
|
|||||||
@@ -82,12 +82,14 @@ type NewDockerBuildExecutorInput struct {
|
|||||||
BuildContext io.Reader
|
BuildContext io.Reader
|
||||||
ImageTag string
|
ImageTag string
|
||||||
Platform string
|
Platform string
|
||||||
|
BuildArgs map[string]*string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||||
type NewDockerNetworkCreateExecutorInput struct {
|
type NewDockerNetworkCreateExecutorInput struct {
|
||||||
EnableIPv4 *bool
|
EnableIPv4 *bool
|
||||||
EnableIPv6 *bool
|
EnableIPv6 *bool
|
||||||
|
RunnerUUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
Remove: true,
|
Remove: true,
|
||||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||||
Dockerfile: input.Dockerfile,
|
Dockerfile: input.Dockerfile,
|
||||||
|
BuildArgs: input.BuildArgs,
|
||||||
}
|
}
|
||||||
platform, err := parsePlatform(input.Platform)
|
platform, err := parsePlatform(input.Platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ func TestCreateFlagsValidate(t *testing.T) {
|
|||||||
|
|
||||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||||
cr := NewContainer(input).(*containerReference)
|
cr, ok := NewContainer(input).(*containerReference)
|
||||||
|
require.True(t, ok)
|
||||||
assert.Equal(t, "linux/arm64", input.Platform)
|
assert.Equal(t, "linux/arm64", input.Platform)
|
||||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
|
|
||||||
if msg.ErrorDetail.Message != "" {
|
if msg.ErrorDetail.Message != "" {
|
||||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||||
return errors.New(msg.Error)
|
return errors.New(msg.ErrorDetail.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Status != "" {
|
if msg.Status != "" {
|
||||||
|
|||||||
@@ -8,12 +8,69 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
networkCreateAttempts = 3
|
||||||
|
networkCreateRetryDelay = time.Second
|
||||||
|
|
||||||
|
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||||
|
// those of another runner sharing the daemon
|
||||||
|
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||||
|
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||||
|
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||||
|
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||||
|
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||||
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// the emptiness check, not the label, is what keeps a live job of another process
|
||||||
|
// sharing this registration safe
|
||||||
|
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
@@ -36,18 +93,45 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
for i := range networkCreateAttempts {
|
||||||
|
if i > 0 {
|
||||||
|
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||||
Driver: "bridge",
|
Driver: "bridge",
|
||||||
Scope: "local",
|
Scope: "local",
|
||||||
EnableIPv4: opts.EnableIPv4,
|
EnableIPv4: opts.EnableIPv4,
|
||||||
EnableIPv6: opts.EnableIPv6,
|
EnableIPv6: opts.EnableIPv6,
|
||||||
})
|
Labels: runnerLabels(opts.RunnerUUID),
|
||||||
if err != nil {
|
}); err == nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !isAddressPoolExhausted(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runnerLabels(runnerUUID string) map[string]string {
|
||||||
|
if runnerUUID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||||
|
// malformed request, so the message is the only discriminator.
|
||||||
|
func isAddressPoolExhausted(err error) bool {
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||||
|
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
@@ -66,6 +150,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
var errs []error
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
if n.Name == name {
|
if n.Name == name {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
@@ -73,16 +158,17 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Network.Containers) == 0 {
|
// it holds a subnet out of the daemon's pool until something reclaims it
|
||||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if len(result.Network.Containers) != 0 {
|
||||||
common.Logger(ctx).Debugf("%v", err)
|
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
act/container/docker_network_test.go
Normal file
50
act/container/docker_network_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
|
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||||
|
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||||
|
// test on its own, since testify has no expectation to match it against.
|
||||||
|
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
|
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||||
|
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
|
{Network: network.Network{ID: "orphan"}},
|
||||||
|
{Network: network.Network{ID: "busy"}},
|
||||||
|
{Network: network.Network{ID: "starting"}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||||
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||||
|
|
||||||
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -30,22 +30,18 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pull := input.ForcePull
|
// skip the pull when the image is already here: either none was forced, or a digest
|
||||||
if !pull {
|
// pins the content so a forced pull could only fetch the same bytes again
|
||||||
|
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||||
logger.Debugf("Image exists? %v", imageExists)
|
logger.Debugf("Image exists? %v", imageExists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||||
}
|
}
|
||||||
|
if imageExists {
|
||||||
if !imageExists {
|
|
||||||
pull = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !pull {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imageRef := cleanImage(ctx, input.Image)
|
imageRef := cleanImage(ctx, input.Image)
|
||||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||||
@@ -61,23 +57,33 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
// the daemon reports a failure that happens after the first progress line in the
|
||||||
|
// stream rather than on the call itself, so both have to be checked
|
||||||
_ = logDockerResponse(logger, reader, err != nil)
|
pullOnce := func(opts client.ImagePullOptions) error {
|
||||||
|
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||||
|
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
return err
|
||||||
|
}
|
||||||
|
return streamErr
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pullOnce(imagePullOptions)
|
||||||
|
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||||
imagePullOptions.RegistryAuth = ""
|
imagePullOptions.RegistryAuth = ""
|
||||||
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
err = pullOnce(imagePullOptions)
|
||||||
|
|
||||||
_ = logDockerResponse(logger, reader, err != nil)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// a registry that is down should not fail a job whose image is already here
|
||||||
|
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||||
|
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||||
@@ -122,6 +128,15 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
|||||||
return imagePullOptions, nil
|
return imagePullOptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPinnedImage(image string) bool {
|
||||||
|
ref, err := reference.ParseAnyReference(image)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, pinned := ref.(reference.Canonical)
|
||||||
|
return pinned
|
||||||
|
}
|
||||||
|
|
||||||
func cleanImage(ctx context.Context, imageName string) string {
|
func cleanImage(ctx context.Context, imageName string) string {
|
||||||
ref, err := reference.ParseAnyReference(imageName)
|
ref, err := reference.ParseAnyReference(imageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -65,3 +69,21 @@ func TestGetImagePullOptions(t *testing.T) {
|
|||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A digest-pinned image is immutable, so its local copy is always current.
|
||||||
|
func TestIsPinnedImage(t *testing.T) {
|
||||||
|
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||||
|
assert.False(t, isPinnedImage("alpine:latest"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||||
|
// whichever of the two shapes the daemon used.
|
||||||
|
func TestLogDockerResponseError(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
streamErr := func(line string) error {
|
||||||
|
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||||
|
}
|
||||||
|
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||||
|
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||||
|
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -42,6 +43,7 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||||
@@ -386,32 +388,71 @@ func (cr *containerReference) find() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict).
|
|
||||||
func isContainerGone(err error) bool {
|
|
||||||
return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) remove() common.Executor {
|
func (cr *containerReference) remove() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
idOrName := cr.id
|
||||||
|
if idOrName == "" && cr.input != nil {
|
||||||
|
idOrName = cr.input.Name
|
||||||
|
}
|
||||||
|
if idOrName == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
_, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
|
// Kill first so removal never waits out a daemon's stop timeout: Docker kills outright
|
||||||
|
// on a forced remove, Podman sends SIGTERM and waits. Only worth it for a container
|
||||||
|
// this started, and removal can still deal with one it could not kill.
|
||||||
|
if cr.id != "" {
|
||||||
|
_, err := cr.cli.ContainerKill(ctx, cr.id, client.ContainerKillOptions{Signal: "SIGKILL"})
|
||||||
|
if err != nil && !cerrdefs.IsConflict(err) && !cerrdefs.IsNotFound(err) {
|
||||||
|
logger.Debugf("Container %s could not be killed: %v", cr.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := cr.cli.ContainerRemove(ctx, idOrName, client.ContainerRemoveOptions{
|
||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
if err != nil && !isContainerGone(err) {
|
switch {
|
||||||
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
case cerrdefs.IsConflict(err):
|
||||||
|
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
||||||
|
// references and the network endpoint only once it finishes
|
||||||
|
cr.waitForRemoval(ctx, idOrName)
|
||||||
|
case err != nil && !cerrdefs.IsNotFound(err):
|
||||||
|
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
||||||
|
return nil // keep the id, the container is still there for a later Remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Removed container: %v", cr.id)
|
logger.Debugf("Removed container: %v", idOrName)
|
||||||
cr.id = ""
|
cr.id = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
||||||
|
// per container, against the one minute the post-job executor allows for the whole
|
||||||
|
// cleanup, so a job with several services can spend most of that budget here
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
waitResult := cr.cli.ContainerWait(ctx, idOrName, client.ContainerWaitOptions{
|
||||||
|
Condition: container.WaitConditionRemoved,
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-waitResult.Result:
|
||||||
|
case <-waitResult.Error:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// the client delivers the result over an unbuffered channel, so leave a receiver
|
||||||
|
// behind or its goroutine parks on the send for the lifetime of the process
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-waitResult.Result:
|
||||||
|
case <-waitResult.Error:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
input := cr.input
|
input := cr.input
|
||||||
@@ -461,6 +502,16 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// When privileged mode is disabled, container.options is workflow-controlled
|
||||||
|
// untrusted input. Strip the HostConfig fields that would let a workflow break
|
||||||
|
// out of the container (host namespaces, capability expansion, security profile
|
||||||
|
// overrides, device and runtime access). Otherwise these survive into the final
|
||||||
|
// HostConfig even though --privileged is forced off.
|
||||||
|
if !hostConfig.Privileged {
|
||||||
|
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||||
|
|
||||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
||||||
@@ -471,8 +522,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
|
|
||||||
hostConfig.Binds = append(hostConfig.Binds, containerConfig.HostConfig.Binds...)
|
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||||
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
|
|
||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
networkMode := hostConfig.NetworkMode
|
networkMode := hostConfig.NetworkMode
|
||||||
@@ -815,23 +865,59 @@ 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)
|
||||||
}
|
}
|
||||||
// Mkdir
|
destPath, err := cr.mkdirInContainer(ctx, destPath)
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
_ = tw.WriteHeader(&tar.Header{
|
|
||||||
Name: 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)
|
||||||
}
|
}
|
||||||
@@ -856,6 +942,10 @@ 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
|
||||||
@@ -1063,6 +1153,77 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
|
||||||
|
// workflow-controlled container.options string that could be used to escape the
|
||||||
|
// container when privileged mode is disabled. It must only be called when the
|
||||||
|
// runner has privileged mode turned off; with privileged mode enabled the
|
||||||
|
// administrator has already opted into host access.
|
||||||
|
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
|
||||||
|
warn := func(option string) {
|
||||||
|
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostConfig.PidMode != "" {
|
||||||
|
warn("--pid")
|
||||||
|
hostConfig.PidMode = ""
|
||||||
|
}
|
||||||
|
if hostConfig.IpcMode != "" {
|
||||||
|
warn("--ipc")
|
||||||
|
hostConfig.IpcMode = ""
|
||||||
|
}
|
||||||
|
if hostConfig.UTSMode != "" {
|
||||||
|
warn("--uts")
|
||||||
|
hostConfig.UTSMode = ""
|
||||||
|
}
|
||||||
|
if hostConfig.CgroupnsMode != "" {
|
||||||
|
warn("--cgroupns")
|
||||||
|
hostConfig.CgroupnsMode = ""
|
||||||
|
}
|
||||||
|
// UsernsMode is set from the runner-controlled input; never let options
|
||||||
|
// override it (e.g. --userns=host disables user namespace remapping).
|
||||||
|
if hostConfig.UsernsMode != "" {
|
||||||
|
warn("--userns")
|
||||||
|
hostConfig.UsernsMode = ""
|
||||||
|
}
|
||||||
|
if len(hostConfig.CapAdd) > 0 {
|
||||||
|
warn("--cap-add")
|
||||||
|
hostConfig.CapAdd = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.SecurityOpt) > 0 {
|
||||||
|
warn("--security-opt")
|
||||||
|
hostConfig.SecurityOpt = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.Devices) > 0 {
|
||||||
|
warn("--device")
|
||||||
|
hostConfig.Devices = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.DeviceCgroupRules) > 0 {
|
||||||
|
warn("--device-cgroup-rule")
|
||||||
|
hostConfig.DeviceCgroupRules = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.DeviceRequests) > 0 {
|
||||||
|
warn("--gpus")
|
||||||
|
hostConfig.DeviceRequests = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.VolumesFrom) > 0 {
|
||||||
|
warn("--volumes-from")
|
||||||
|
hostConfig.VolumesFrom = nil
|
||||||
|
}
|
||||||
|
if hostConfig.Runtime != "" {
|
||||||
|
warn("--runtime")
|
||||||
|
hostConfig.Runtime = ""
|
||||||
|
}
|
||||||
|
if hostConfig.CgroupParent != "" {
|
||||||
|
warn("--cgroup-parent")
|
||||||
|
hostConfig.CgroupParent = ""
|
||||||
|
}
|
||||||
|
if len(hostConfig.Sysctls) > 0 {
|
||||||
|
warn("--sysctl")
|
||||||
|
hostConfig.Sysctls = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||||
@@ -1108,6 +1269,34 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
return config, hostConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||||
|
func bindTarget(bind string) string {
|
||||||
|
parsed, err := loader.ParseVolume(bind)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Target
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayVolumes appends src's volumes to dst, dropping the dst ones they mount over. Docker
|
||||||
|
// rejects two mounts on one target, so the volumes declared last have to win.
|
||||||
|
func overlayVolumes(dst, src *container.HostConfig) {
|
||||||
|
claimed := map[string]bool{}
|
||||||
|
for _, bind := range src.Binds {
|
||||||
|
if target := bindTarget(bind); target != "" {
|
||||||
|
claimed[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mt := range src.Mounts {
|
||||||
|
claimed[mt.Target] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.Binds = append(slices.DeleteFunc(slices.Clone(dst.Binds),
|
||||||
|
func(bind string) bool { return claimed[bindTarget(bind)] }), src.Binds...)
|
||||||
|
dst.Mounts = append(slices.DeleteFunc(slices.Clone(dst.Mounts),
|
||||||
|
func(mt mount.Mount) bool { return claimed[mt.Target] }), src.Mounts...)
|
||||||
|
}
|
||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []glob.Glob
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"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"
|
||||||
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"
|
||||||
@@ -91,6 +93,11 @@ 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)
|
||||||
@@ -121,6 +128,26 @@ func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts
|
|||||||
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
type endlessReader struct {
|
type endlessReader struct {
|
||||||
io.Reader
|
io.Reader
|
||||||
}
|
}
|
||||||
@@ -314,15 +341,37 @@ func TestDockerWaitFailure(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
||||||
|
// when they are a symlink, everything else does not exist.
|
||||||
|
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
||||||
|
for containerPath, target := range existing {
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
||||||
|
}
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
||||||
|
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
||||||
func TestDockerCopyTarStream(t *testing.T) {
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
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 {
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
if opts.DestinationPath != "/run" || opts.Content == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(opts.Content)
|
||||||
|
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
||||||
|
mkdirNames = append(mkdirNames, hdr.Name)
|
||||||
|
}
|
||||||
|
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 == "/var/run/act" && opts.Content != nil
|
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
@@ -332,46 +381,33 @@ func TestDockerCopyTarStream(t *testing.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)
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
||||||
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
|
||||||
|
|
||||||
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{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -380,39 +416,50 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
||||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
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
|
||||||
// be logged as one.
|
// be logged as one.
|
||||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
wantLogs bool
|
wantWait bool
|
||||||
|
wantFailure bool
|
||||||
}{
|
}{
|
||||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")},
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||||
{name: "removed cleanly", err: nil},
|
{name: "removed cleanly", err: nil},
|
||||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantLogs: true},
|
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := test.NewNullLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
|
if tc.wantWait {
|
||||||
|
removed := make(chan container.WaitResponse, 1)
|
||||||
|
removed <- container.WaitResponse{}
|
||||||
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||||
|
}
|
||||||
cr := &containerReference{id: "abc", cli: client}
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
require.NoError(t, cr.remove()(ctx))
|
require.NoError(t, cr.remove()(ctx))
|
||||||
assert.Empty(t, cr.id)
|
// a failure keeps the id, so a later Remove() can retry it
|
||||||
|
if tc.wantFailure {
|
||||||
if tc.wantLogs {
|
assert.Equal(t, "abc", cr.id)
|
||||||
assert.Len(t, hook.AllEntries(), 1)
|
assert.Len(t, hook.AllEntries(), 1)
|
||||||
} else {
|
} else {
|
||||||
|
assert.Empty(t, cr.id)
|
||||||
assert.Empty(t, hook.AllEntries())
|
assert.Empty(t, hook.AllEntries())
|
||||||
}
|
}
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
@@ -420,6 +467,20 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container whose id was never learned, because find() could not reach the daemon or
|
||||||
|
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||||
|
// was never started here, so it is not worth a kill of its own.
|
||||||
|
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
|
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||||
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
|
|
||||||
|
require.NoError(t, cr.remove()(ctx))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||||
// daemon with a torn-down container.
|
// daemon with a torn-down container.
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||||
@@ -541,9 +602,8 @@ func TestDockerCopyToSymlinkPath(t *testing.T) {
|
|||||||
_ = rc.Close()(ctx)
|
_ = rc.Close()(ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
||||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
// exact step that fails on a broken daemon.
|
||||||
// step that fails on the broken daemon.
|
|
||||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
@@ -624,6 +684,110 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
|
||||||
|
dangerous := func() *container.HostConfig {
|
||||||
|
return &container.HostConfig{
|
||||||
|
PidMode: "host",
|
||||||
|
IpcMode: "host",
|
||||||
|
UTSMode: "host",
|
||||||
|
CgroupnsMode: "host",
|
||||||
|
UsernsMode: "host",
|
||||||
|
CapAdd: []string{"ALL"},
|
||||||
|
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||||
|
VolumesFrom: []string{"other"},
|
||||||
|
Runtime: "runc",
|
||||||
|
Resources: container.Resources{
|
||||||
|
CgroupParent: "/custom",
|
||||||
|
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||||
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||||
|
},
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostConfig := dangerous()
|
||||||
|
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||||
|
|
||||||
|
assert.Empty(t, string(hostConfig.PidMode))
|
||||||
|
assert.Empty(t, string(hostConfig.IpcMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UTSMode))
|
||||||
|
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||||
|
assert.Empty(t, hostConfig.CapAdd)
|
||||||
|
assert.Empty(t, hostConfig.SecurityOpt)
|
||||||
|
assert.Empty(t, hostConfig.Devices)
|
||||||
|
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||||
|
assert.Empty(t, hostConfig.VolumesFrom)
|
||||||
|
assert.Empty(t, hostConfig.Runtime)
|
||||||
|
assert.Empty(t, hostConfig.CgroupParent)
|
||||||
|
assert.Empty(t, hostConfig.Sysctls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||||
|
// OS-independent options only: --device parsing requires a linux/windows
|
||||||
|
// server OS, which is not guaranteed for the test host.
|
||||||
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||||
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||||
|
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||||
|
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||||
|
|
||||||
|
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Options: dangerousOptions,
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
UsernsMode: "private",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Privileged: false,
|
||||||
|
UsernsMode: container.UsernsMode("private"),
|
||||||
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, hostConfig.Privileged)
|
||||||
|
assert.Empty(t, string(hostConfig.PidMode))
|
||||||
|
assert.Empty(t, string(hostConfig.IpcMode))
|
||||||
|
assert.Empty(t, string(hostConfig.UTSMode))
|
||||||
|
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||||
|
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||||
|
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||||
|
assert.Empty(t, hostConfig.CapAdd)
|
||||||
|
assert.Empty(t, hostConfig.SecurityOpt)
|
||||||
|
assert.Empty(t, hostConfig.VolumesFrom)
|
||||||
|
assert.Empty(t, hostConfig.Runtime)
|
||||||
|
assert.Empty(t, hostConfig.CgroupParent)
|
||||||
|
assert.Empty(t, hostConfig.Sysctls)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("privileged preserves options", func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Privileged: true,
|
||||||
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||||
|
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||||
|
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger, _ := test.NewNullLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
@@ -660,3 +824,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.Empty(t, hostConf.Binds)
|
assert.Empty(t, hostConf.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||||
|
assert.Empty(t, hostConf.Mounts)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -72,3 +73,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
} else {
|
} else {
|
||||||
wd = e.Path
|
wd = e.Path
|
||||||
}
|
}
|
||||||
|
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||||
|
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||||
|
defer common.FlushWriter(e.StdOut)
|
||||||
|
|
||||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
|||||||
return strings.Join(paths, ":")
|
return strings.Join(paths, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||||
|
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||||
|
|
||||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"os": "Linux",
|
"os": "Linux",
|
||||||
"arch": RunnerArch(ctx),
|
"arch": RunnerArch(ctx),
|
||||||
"temp": "/tmp",
|
"temp": "/tmp",
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
"tool_cache": DefaultToolCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
"github.com/rhysd/actionlint"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
|
||||||
switch search.Kind() {
|
|
||||||
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
|
||||||
return strings.Contains(
|
|
||||||
strings.ToLower(impl.coerceToString(search).String()),
|
|
||||||
strings.ToLower(impl.coerceToString(item).String()),
|
|
||||||
), nil
|
|
||||||
|
|
||||||
case reflect.Slice:
|
|
||||||
for i := 0; i < search.Len(); i++ {
|
|
||||||
arrayItem := search.Index(i).Elem()
|
|
||||||
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if isEqual, ok := result.(bool); ok && isEqual {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return strings.HasPrefix(
|
|
||||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
|
||||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return strings.HasSuffix(
|
|
||||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
|
||||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
passThrough = iota
|
|
||||||
bracketOpen
|
|
||||||
bracketClose
|
|
||||||
)
|
|
||||||
|
|
||||||
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
|
||||||
input := impl.coerceToString(str).String()
|
|
||||||
var output strings.Builder
|
|
||||||
replacementIndex := ""
|
|
||||||
|
|
||||||
state := passThrough
|
|
||||||
for _, character := range input {
|
|
||||||
switch state {
|
|
||||||
case passThrough: // normal buffer output
|
|
||||||
switch character {
|
|
||||||
case '{':
|
|
||||||
state = bracketOpen
|
|
||||||
|
|
||||||
case '}':
|
|
||||||
state = bracketClose
|
|
||||||
|
|
||||||
default:
|
|
||||||
output.WriteRune(character)
|
|
||||||
}
|
|
||||||
|
|
||||||
case bracketOpen: // found {
|
|
||||||
switch character {
|
|
||||||
case '{':
|
|
||||||
output.WriteString("{")
|
|
||||||
replacementIndex = ""
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
case '}':
|
|
||||||
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
|
||||||
}
|
|
||||||
|
|
||||||
replacementIndex = ""
|
|
||||||
|
|
||||||
if len(replaceValue) <= int(index) {
|
|
||||||
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
|
||||||
}
|
|
||||||
|
|
||||||
output.WriteString(impl.coerceToString(replaceValue[index]).String())
|
|
||||||
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
default:
|
|
||||||
replacementIndex += string(character)
|
|
||||||
}
|
|
||||||
|
|
||||||
case bracketClose: // found }
|
|
||||||
switch character {
|
|
||||||
case '}':
|
|
||||||
output.WriteString("}")
|
|
||||||
replacementIndex = ""
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
default:
|
|
||||||
panic("Invalid format parser state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state != passThrough {
|
|
||||||
switch state {
|
|
||||||
case bracketOpen:
|
|
||||||
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
|
||||||
|
|
||||||
case bracketClose:
|
|
||||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
separator := impl.coerceToString(sep).String()
|
|
||||||
switch array.Kind() {
|
|
||||||
case reflect.Slice:
|
|
||||||
var items []string
|
|
||||||
for i := 0; i < array.Len(); i++ {
|
|
||||||
items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(items, separator), nil
|
|
||||||
default:
|
|
||||||
return strings.Join([]string{impl.coerceToString(array).String()}, separator), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
|
||||||
if value.Kind() == reflect.Invalid {
|
|
||||||
return "null", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(json), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
|
||||||
if value.Kind() != reflect.String {
|
|
||||||
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
var data any
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(value.String()), &data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
|
||||||
var ps []gitignore.Pattern
|
|
||||||
|
|
||||||
const cwdPrefix = "." + string(filepath.Separator)
|
|
||||||
const excludeCwdPrefix = "!" + cwdPrefix
|
|
||||||
for _, path := range paths {
|
|
||||||
if path.Kind() == reflect.String {
|
|
||||||
cleanPath := path.String()
|
|
||||||
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
|
||||||
cleanPath = cleanPath[len(cwdPrefix):]
|
|
||||||
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
|
||||||
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
|
||||||
}
|
|
||||||
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
|
||||||
} else {
|
|
||||||
return "", errors.New("Non-string path passed to hashFiles")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matcher := gitignore.NewMatcher(ps)
|
|
||||||
|
|
||||||
var files []string
|
|
||||||
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
|
||||||
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
|
||||||
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
files = append(files, path)
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hasher := sha256.New()
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(hasher, f); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to Close file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
|
||||||
needs := job.Needs()
|
|
||||||
|
|
||||||
for _, need := range needs {
|
|
||||||
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
|
||||||
needs = append(needs, parentNeeds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return needs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) always() (bool, error) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
jobs := impl.config.Run.Workflow.Jobs
|
|
||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
if jobs[needs].NeedsResult() != "success" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jobStatus returns the current job status, treating a nil Job context as an
|
|
||||||
// empty status so status-check functions never panic on a nil dereference.
|
|
||||||
func (impl *interperterImpl) jobStatus() string {
|
|
||||||
if impl.env.Job == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return impl.env.Job.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "success", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
jobs := impl.config.Run.Workflow.Jobs
|
|
||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
if jobs[needs].NeedsResult() == "failure" {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "failure", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "cancelled", nil
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFunctionContains(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"contains('search', 'item') }}", false, "contains-str-str"},
|
|
||||||
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
|
||||||
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
|
||||||
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
|
||||||
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
|
||||||
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
|
||||||
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
|
||||||
{`contains(null, '') }}`, true, "contains-null-str"},
|
|
||||||
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
|
||||||
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
|
||||||
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
|
||||||
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
|
||||||
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
|
||||||
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
|
||||||
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
|
||||||
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
|
||||||
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionStartsWith(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
|
||||||
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
|
||||||
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
|
||||||
{"startsWith(123, 's') }}", false, "startswith-string"},
|
|
||||||
{"startsWith(123, '12') }}", true, "startswith-string"},
|
|
||||||
{"startsWith('123', 12) }}", true, "startswith-string"},
|
|
||||||
{"startsWith(null, '42') }}", false, "startswith-string"},
|
|
||||||
{"startsWith('null', null) }}", true, "startswith-string"},
|
|
||||||
{"startsWith('null', '') }}", true, "startswith-string"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionEndsWith(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
|
||||||
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith('null', null) }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('null', '') }}", true, "endsWith-string"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionJoin(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
|
||||||
{"join('string', ',')", "string", "join-str"},
|
|
||||||
{"join(1, ',')", "1", "join-number"},
|
|
||||||
{"join(null, ',')", "", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionToJSON(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
|
||||||
{"toJSON(null)", "null", "toJSON-null"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Env: map[string]string{
|
|
||||||
"key": "value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionFromJSON(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
|
||||||
"foo": "bar",
|
|
||||||
}, "fromJSON"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionHashFiles(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
|
||||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
|
||||||
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
|
||||||
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
|
||||||
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
|
||||||
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
|
||||||
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
|
||||||
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
workdir, err := filepath.Abs("testdata")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionFormat(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
error any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"format('text')", "text", nil, "format-plain-string"},
|
|
||||||
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
|
||||||
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
|
||||||
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
|
||||||
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
|
||||||
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
|
||||||
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
|
||||||
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
|
||||||
{"format(true)", "true", nil, "format-with-primitive-args"},
|
|
||||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
|
||||||
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
|
||||||
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
|
||||||
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
|
||||||
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
|
||||||
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
if tt.error != nil {
|
|
||||||
assert.Equal(t, tt.error, err.Error())
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusFunctionsNilJob(t *testing.T) {
|
|
||||||
// A nil Job context must not panic: the status-check functions should treat
|
|
||||||
// it as an empty status and return false rather than dereferencing nil.
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
context string
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"cancelled()", "job", "cancelled-nil-job"},
|
|
||||||
{"success()", "step", "step-success-nil-job"},
|
|
||||||
{"failure()", "step", "step-failure-nil-job"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, false, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,632 +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"
|
|
||||||
)
|
|
||||||
|
|
||||||
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) {
|
|
||||||
assert.True(t, math.IsNaN(output.(float64)))
|
|
||||||
} 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
act/exprparser/testdata/for-hashing-1.txt
vendored
1
act/exprparser/testdata/for-hashing-1.txt
vendored
@@ -1 +0,0 @@
|
|||||||
Hello
|
|
||||||
1
act/exprparser/testdata/for-hashing-2.txt
vendored
1
act/exprparser/testdata/for-hashing-2.txt
vendored
@@ -1 +0,0 @@
|
|||||||
World!
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Knock knock!
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Anybody home?
|
|
||||||
138
act/ghcontext/github_context.go
Normal file
138
act/ghcontext/github_context.go
Normal file
@@ -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/actions-proto-go/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/actions-proto-go/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/actions-proto-go/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
act/model/testdata/nested/success.yml
vendored
9
act/model/testdata/nested/success.yml
vendored
@@ -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!"
|
|
||||||
50
act/model/testdata/strategy/push.yml
vendored
50
act/model/testdata/strategy/push.yml
vendored
@@ -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,907 +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 {
|
|
||||||
for k, cron := range v.(map[string]any) {
|
|
||||||
if k != "cron" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
allSchedules = append(allSchedules, cron.(string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
|
||||||
return 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 nil
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
log.Errorf("matrix validation error: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
val[k] = normalized
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
if m := j.Matrix(); m != nil {
|
|
||||||
includes := make([]map[string]any, 0)
|
|
||||||
extraIncludes := make([]map[string]any, 0)
|
|
||||||
for _, v := range m["include"] {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case []any:
|
|
||||||
for _, i := range t {
|
|
||||||
i := i.(map[string]any)
|
|
||||||
extraInclude := true
|
|
||||||
for k := range i {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
includes = append(includes, i)
|
|
||||||
extraInclude = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extraInclude {
|
|
||||||
extraIncludes = append(extraIncludes, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case any:
|
|
||||||
v := v.(map[string]any)
|
|
||||||
extraInclude := true
|
|
||||||
for k := range v {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
includes = append(includes, v)
|
|
||||||
extraInclude = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extraInclude {
|
|
||||||
extraIncludes = append(extraIncludes, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m, "include")
|
|
||||||
|
|
||||||
excludes := make([]map[string]any, 0)
|
|
||||||
for _, e := range m["exclude"] {
|
|
||||||
e := e.(map[string]any)
|
|
||||||
for k := range e {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
excludes = append(excludes, e)
|
|
||||||
} 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/actions-proto-go/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +129,16 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
|
|||||||
return action, err
|
return action, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cachedActionTar returns the action's tree from the action cache, which only a remote action
|
||||||
|
// has an entry in.
|
||||||
|
func cachedActionTar(ctx context.Context, step actionStep, name, includePrefix string) (io.ReadCloser, error) {
|
||||||
|
remote, ok := step.(*stepActionRemote)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("action %q is a remote action but runs as %T", name, step)
|
||||||
|
}
|
||||||
|
return step.getRunContext().Config.ActionCache.GetTarArchive(ctx, remote.cacheDir, remote.resolvedSha, includePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
@@ -147,8 +157,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rc.Config != nil && rc.Config.ActionCache != nil {
|
if rc.Config != nil && rc.Config.ActionCache != nil {
|
||||||
raction := step.(*stepActionRemote)
|
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
||||||
ta, err := rc.Config.ActionCache.GetTarArchive(ctx, raction.cacheDir, raction.resolvedSha, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,8 +360,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
}
|
}
|
||||||
defer buildContext.Close()
|
defer buildContext.Close()
|
||||||
} else if rc.Config.ActionCache != nil {
|
} else if rc.Config.ActionCache != nil {
|
||||||
rstep := step.(*stepActionRemote)
|
buildContext, err = cachedActionTar(ctx, step, actionName, contextDir)
|
||||||
buildContext, err = rc.Config.ActionCache.GetTarArchive(ctx, rstep.cacheDir, rstep.resolvedSha, contextDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -364,6 +372,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
ImageTag: image,
|
ImageTag: image,
|
||||||
BuildContext: buildContext,
|
BuildContext: buildContext,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
|
BuildArgs: rc.proxyBuildArgs(),
|
||||||
})
|
})
|
||||||
if buildContext == nil {
|
if buildContext == nil {
|
||||||
// Held across the whole build: the daemon drains contextDir lazily.
|
// Held across the whole build: the daemon drains contextDir lazily.
|
||||||
@@ -472,10 +481,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
@@ -589,8 +595,8 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
|||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
if _, ok := step.(*stepActionRemote); ok {
|
if sar, ok := step.(*stepActionRemote); ok {
|
||||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
actionDir = sar.actionDir()
|
||||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
actionPath = newRemoteAction(stepModel.Uses).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/actions-proto-go/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/actions-proto-go/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 {
|
||||||
|
|||||||
@@ -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/actions-proto-go/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/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/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"
|
||||||
|
|||||||
@@ -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/actions-proto-go/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ 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/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.dev/actions-proto-go/pkg/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actions-proto-go/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"
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
@@ -42,18 +40,6 @@ func requireDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
|
||||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
|
||||||
// gating lets the rest of the suite run offline without these failing.
|
|
||||||
func requireNetwork(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: network unavailable: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
func requireHostTools(t *testing.T, tools ...string) {
|
||||||
|
|||||||
@@ -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/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
@@ -226,11 +227,16 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The setup section of the job log: download the actions, run the pre steps, then name the job.
|
// The setup section of the job log. The started hook goes first, so what it sets up is
|
||||||
|
// in place for the first action download and the first step.
|
||||||
|
preSteps = append(preSteps, rc.runJobStartedHook)
|
||||||
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||||
preSteps = append(preSteps, stepPreSteps...)
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||||
|
|
||||||
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
|
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -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/actions-proto-go/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"
|
||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||||
@@ -46,6 +47,7 @@ func TestJobExecutor(t *testing.T) {
|
|||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
115
act/runner/job_hooks.go
Normal file
115
act/runner/job_hooks.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHub's job-hook variables, read as a fallback when the settings are unset.
|
||||||
|
const (
|
||||||
|
jobStartedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_STARTED"
|
||||||
|
jobCompletedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kept apart from the per-step file-command files, which are truncated on every step.
|
||||||
|
const (
|
||||||
|
hookEnvFileCommand = "workflow/hook-envs.txt"
|
||||||
|
hookPathFileCommand = "workflow/hook-path.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rc *RunContext) runJobStartedHook(ctx context.Context) error {
|
||||||
|
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobStartedHook, rc.Config.Env[jobStartedHookEnv]), "job started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) runJobCompletedHook(ctx context.Context) error {
|
||||||
|
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobCompletedHook, rc.Config.Env[jobCompletedHookEnv]), "job completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runJobHook runs one hook in the job environment. Either hook failing fails the job, as
|
||||||
|
// on GitHub, where the operator is responsible for the hook's own resilience.
|
||||||
|
func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) error {
|
||||||
|
if hookPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, shell := hookCommand(hookPath)
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
||||||
|
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||||
|
if shell != "" {
|
||||||
|
rawLogger.Infof("shell: %s", shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := maps.Clone(rc.GetEnv())
|
||||||
|
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||||
|
maps.Copy(env, jobContainer.Env)
|
||||||
|
}
|
||||||
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
|
err := rc.setupHookFileCommands(ctx, env)
|
||||||
|
if err == nil {
|
||||||
|
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx)
|
||||||
|
}
|
||||||
|
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||||
|
// failing still hands it to the job.
|
||||||
|
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fmt.Errorf("the %s hook %q failed: %w", name, hookPath, err)
|
||||||
|
// Flip the job status the way a failing pre step does, so success()-default main steps
|
||||||
|
// skip and the task is reported failed.
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHookFileCommands points the hook at its GITHUB_ENV and GITHUB_PATH files, so it can
|
||||||
|
// export to the job's steps, and truncates them so the second hook does not re-read what
|
||||||
|
// the first one wrote.
|
||||||
|
func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]string) error {
|
||||||
|
actPath := rc.JobContainer.GetActPath()
|
||||||
|
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
||||||
|
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
||||||
|
env["GITEA_ENV"] = env["GITHUB_ENV"]
|
||||||
|
env["GITEA_PATH"] = env["GITHUB_PATH"]
|
||||||
|
|
||||||
|
return rc.JobContainer.Copy(actPath,
|
||||||
|
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
||||||
|
&container.FileEntry{Name: hookPathFileCommand, Mode: 0o666},
|
||||||
|
)(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||||
|
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||||
|
// gives `run:` steps — a hook sets its own. See docs/adrs/1751-runner-job-hooks.md there.
|
||||||
|
// The second return value is how the invocation is shown in the log, empty when the file is
|
||||||
|
// executed directly.
|
||||||
|
func hookCommand(hookPath string) (cmd []string, shell string) {
|
||||||
|
switch strings.ToLower(path.Ext(hookPath)) {
|
||||||
|
case ".sh":
|
||||||
|
return []string{"bash", "-e", hookPath}, "bash -e {0}"
|
||||||
|
case ".ps1":
|
||||||
|
return []string{"pwsh", "-command", ". '" + hookPath + "'"}, `pwsh -command ". '{0}'"`
|
||||||
|
default:
|
||||||
|
return []string{hookPath}, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
162
act/runner/job_hooks_test.go
Normal file
162
act/runner/job_hooks_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hookContainer records the command a hook was run with and answers with what the hook
|
||||||
|
// wrote to its GITHUB_ENV and GITHUB_PATH files.
|
||||||
|
type hookContainer struct {
|
||||||
|
fakeContainer
|
||||||
|
cmd []string
|
||||||
|
env map[string]string
|
||||||
|
err error
|
||||||
|
envFile map[string]string
|
||||||
|
pathTar []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) ToContainerPath(path string) string { return path }
|
||||||
|
func (c *hookContainer) IsEnvironmentCaseInsensitive() bool { return false }
|
||||||
|
|
||||||
|
func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
|
||||||
|
return map[string]any{"os": "Linux"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
c.cmd, c.env = command, env
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
maps.Copy(*env, c.envFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) {
|
||||||
|
return io.NopCloser(bytes.NewReader(c.pathTar)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is
|
||||||
|
// silenced so the hook's job-log output does not reach the test output.
|
||||||
|
func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) {
|
||||||
|
// Env is left nil so that it is built from the config, as it is for a real job.
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: config,
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
JobContainer: jobContainer,
|
||||||
|
}
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
return rc, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobHook(t *testing.T) {
|
||||||
|
t.Run("runs the hook with the job environment", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{
|
||||||
|
JobStartedHook: "/hooks/started.sh",
|
||||||
|
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
|
||||||
|
// The setting wins over the environment variable.
|
||||||
|
assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd)
|
||||||
|
assert.Equal(t, "value", jobContainer.env["A_VAR"])
|
||||||
|
// The github environment is there too, so a hook can tell which job it runs for.
|
||||||
|
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
||||||
|
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
||||||
|
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each hook reads its own variable, so a swapped constant cannot pass.
|
||||||
|
t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
|
||||||
|
for name, hook := range map[string]struct {
|
||||||
|
env string
|
||||||
|
run func(*RunContext, context.Context) error
|
||||||
|
}{
|
||||||
|
"started": {jobStartedHookEnv, (*RunContext).runJobStartedHook},
|
||||||
|
"completed": {jobCompletedHookEnv, (*RunContext).runJobCompletedHook},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{Env: map[string]string{hook.env: "/from/env.sh"}})
|
||||||
|
|
||||||
|
require.NoError(t, hook.run(rc, ctx))
|
||||||
|
assert.Equal(t, []string{"bash", "-e", "/from/env.sh"}, jobContainer.cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exports what the hook wrote to GITHUB_ENV and GITHUB_PATH", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{
|
||||||
|
envFile: map[string]string{"FROM_HOOK": "1"},
|
||||||
|
pathTar: tarArchive(t, tarEntry{name: "hook-path.txt", body: "/opt/tool/bin\n"}),
|
||||||
|
}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
|
||||||
|
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
|
||||||
|
assert.Equal(t, []string{"/opt/tool/bin"}, rc.ExtraPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failing hook fails the job", func(t *testing.T) {
|
||||||
|
rc, ctx := newHookRunContext(&hookContainer{err: errors.New("boom")}, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||||
|
|
||||||
|
err := rc.runJobStartedHook(ctx)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, `the job started hook "/hooks/started.sh" failed`)
|
||||||
|
require.ErrorContains(t, err, "boom")
|
||||||
|
// The failure has to flip the job status, or success()-default steps would still
|
||||||
|
// run and the task would be reported successful despite the missing setup.
|
||||||
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||||
|
require.ErrorContains(t, common.JobError(ctx), "boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is a no-op without a hook", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
require.NoError(t, rc.runJobCompletedHook(ctx))
|
||||||
|
assert.Nil(t, jobContainer.cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// actions/runner deliberately runs a hook without the flags it gives `run:` steps, and an
|
||||||
|
// executable without a known extension speaks for itself through its shebang.
|
||||||
|
func TestHookCommand(t *testing.T) {
|
||||||
|
for hookPath, want := range map[string]struct {
|
||||||
|
cmd []string
|
||||||
|
shell string
|
||||||
|
}{
|
||||||
|
"/hooks/started.sh": {[]string{"bash", "-e", "/hooks/started.sh"}, "bash -e {0}"},
|
||||||
|
"/hooks/started.PS1": {[]string{"pwsh", "-command", ". '/hooks/started.PS1'"}, `pwsh -command ". '{0}'"`},
|
||||||
|
"/hooks/started": {[]string{"/hooks/started"}, ""},
|
||||||
|
} {
|
||||||
|
cmd, shell := hookCommand(hookPath)
|
||||||
|
assert.Equal(t, want.cmd, cmd, hookPath)
|
||||||
|
assert.Equal(t, want.shell, shell, hookPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -167,6 +170,76 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
|||||||
|
|
||||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||||
|
|
||||||
|
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
|
||||||
|
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
|
||||||
|
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
|
||||||
|
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
|
||||||
|
var secretValueEncoders = []func(string) string{
|
||||||
|
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||||
|
base64ShiftEncoder(1),
|
||||||
|
base64ShiftEncoder(2),
|
||||||
|
jsonStringEscape,
|
||||||
|
jsonStringEscapeNoHTML,
|
||||||
|
url.QueryEscape,
|
||||||
|
url.PathEscape,
|
||||||
|
}
|
||||||
|
|
||||||
|
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
||||||
|
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
||||||
|
const minShiftedBase64Len = 8
|
||||||
|
|
||||||
|
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
|
||||||
|
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
|
||||||
|
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
|
||||||
|
// the base64 of the token alone only when the prefix length happens to be a multiple of
|
||||||
|
// three; at the other two alignments the encoding of the whole value differs. Encoding
|
||||||
|
// the secret behind shift filler bytes reproduces those alignments, which is what the
|
||||||
|
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
|
||||||
|
//
|
||||||
|
// The leading group (filler mixed with the secret's first bytes) and the trailing group
|
||||||
|
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
|
||||||
|
// group-aligned middle that does appear verbatim in the log.
|
||||||
|
func base64ShiftEncoder(shift int) func(string) string {
|
||||||
|
return func(v string) string {
|
||||||
|
buf := make([]byte, shift+len(v))
|
||||||
|
copy(buf[shift:], v)
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||||
|
// Keep only the aligned middle, and only when enough of it is left to be a
|
||||||
|
// distinctive pattern rather than a fragment that matches unrelated output.
|
||||||
|
if len(encoded) < 8+minShiftedBase64Len {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return encoded[4 : len(encoded)-4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
||||||
|
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
||||||
|
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||||
|
// that do not. When v has none of those characters both forms are equal and deduplicated.
|
||||||
|
func jsonStringEscape(v string) string {
|
||||||
|
encoded, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return string(encoded[1 : len(encoded)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStringEscapeNoHTML is jsonStringEscape without HTML escaping, matching the JSON a
|
||||||
|
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
|
||||||
|
// masked in that form too.
|
||||||
|
func jsonStringEscapeNoHTML(v string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// Encode appends a newline; drop it along with the surrounding quotes.
|
||||||
|
encoded := strings.TrimRight(buf.String(), "\n")
|
||||||
|
return encoded[1 : len(encoded)-1]
|
||||||
|
}
|
||||||
|
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
ret := oldnew
|
ret := oldnew
|
||||||
|
|
||||||
@@ -182,6 +255,21 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The encoded forms are derived from the whole value: a multi-line secret is
|
||||||
|
// encoded as one string, not line by line.
|
||||||
|
trimmed := strings.TrimSpace(v)
|
||||||
|
if len(trimmed) <= 1 {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
for _, encode := range secretValueEncoders {
|
||||||
|
encoded := encode(trimmed)
|
||||||
|
// An encoding that leaves the value unchanged is already masked above.
|
||||||
|
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ret = append(ret, encoded, "***")
|
||||||
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +282,18 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
|||||||
}
|
}
|
||||||
oldnew = slices.Clip(oldnew)
|
oldnew = slices.Clip(oldnew)
|
||||||
defReplacer := strings.NewReplacer(oldnew...)
|
defReplacer := strings.NewReplacer(oldnew...)
|
||||||
|
|
||||||
|
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
|
||||||
|
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
|
||||||
|
// length, instead of encoding every secret and mask again for each log line.
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
masksRef *[]string
|
||||||
|
pairs []string
|
||||||
|
masked int
|
||||||
|
replacer *strings.Replacer
|
||||||
|
)
|
||||||
|
|
||||||
return func(entry *logrus.Entry) *logrus.Entry {
|
return func(entry *logrus.Entry) *logrus.Entry {
|
||||||
if insecureSecrets {
|
if insecureSecrets {
|
||||||
return entry
|
return entry
|
||||||
@@ -203,15 +303,26 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
|||||||
|
|
||||||
if len(*masks) == 0 {
|
if len(*masks) == 0 {
|
||||||
entry.Message = defReplacer.Replace(entry.Message)
|
entry.Message = defReplacer.Replace(entry.Message)
|
||||||
} else {
|
return entry
|
||||||
cmasker := oldnew
|
|
||||||
|
|
||||||
for _, v := range *masks {
|
|
||||||
cmasker = AppendSecretMasker(cmasker, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
mu.Lock()
|
||||||
|
// A composite action logs through the same masker with its own mask slice, so a
|
||||||
|
// different slice starts the cache over.
|
||||||
|
if masksRef != masks {
|
||||||
|
masksRef, pairs, masked, replacer = masks, oldnew, 0, nil
|
||||||
}
|
}
|
||||||
|
if replacer == nil || masked != len(*masks) {
|
||||||
|
for _, v := range (*masks)[masked:] {
|
||||||
|
pairs = AppendSecretMasker(pairs, v)
|
||||||
|
}
|
||||||
|
masked = len(*masks)
|
||||||
|
replacer = strings.NewReplacer(pairs...)
|
||||||
|
}
|
||||||
|
cmasker := replacer
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
entry.Message = cmasker.Replace(entry.Message)
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -59,6 +61,136 @@ func TestValueMasker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||||
|
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||||
|
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||||
|
secret := `p@ss w"rd/1`
|
||||||
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
}{
|
||||||
|
{"verbatim", "the token is " + secret},
|
||||||
|
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
||||||
|
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
||||||
|
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
||||||
|
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
||||||
|
|
||||||
|
assert.Contains(t, entry.Message, "***")
|
||||||
|
assert.NotContains(t, entry.Message, secret)
|
||||||
|
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||||
|
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A secret containing " together with <, > or & serializes to JSON differently depending
|
||||||
|
// on the runtime: act's own toJSON (and Go) HTML-escape <>&, while a JavaScript
|
||||||
|
// (JSON.stringify) or .NET action leaves them literal. The secret must be masked in either
|
||||||
|
// form, so a JS-serialized JSON body does not leak it.
|
||||||
|
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||||
|
secret := `a"<b>&c`
|
||||||
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
form string
|
||||||
|
}{
|
||||||
|
{"html escaped (act toJSON / Go)", jsonStringEscape(secret)},
|
||||||
|
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: `{"t":"` + tc.form + `"}`})
|
||||||
|
|
||||||
|
assert.Contains(t, entry.Message, "***")
|
||||||
|
assert.NotContains(t, entry.Message, tc.form)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||||
|
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||||
|
masks := []string{"s3cr3t value"}
|
||||||
|
masker := valueMasker(false, nil)
|
||||||
|
|
||||||
|
entry := masker(&logrus.Entry{
|
||||||
|
Context: WithMasks(t.Context(), &masks),
|
||||||
|
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "encoded: ***", entry.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||||
|
// own base64 only appears when the prefix length is a multiple of three. The other two
|
||||||
|
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||||
|
// the token to anyone who can decode the log.
|
||||||
|
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||||
|
secret := "s3cr3t-token-value"
|
||||||
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
|
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||||
|
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||||
|
t.Run(prefix, func(t *testing.T) {
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||||
|
|
||||||
|
assert.Contains(t, entry.Message, "***")
|
||||||
|
// The aligned middle of the secret must be gone, so the payload can no longer be
|
||||||
|
// decoded back into the token.
|
||||||
|
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||||
|
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||||
|
if err == nil {
|
||||||
|
assert.NotContains(t, string(decoded), secret)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The masker caches its replacer, so it has to notice both a mask appended to the same
|
||||||
|
// slice and a composite action logging with a slice of its own.
|
||||||
|
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||||
|
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"})
|
||||||
|
mask := func(masks *[]string, message string) string {
|
||||||
|
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
||||||
|
}
|
||||||
|
|
||||||
|
job := []string{"first mask"}
|
||||||
|
assert.Equal(t, "a *** and ***", mask(&job, "a first mask and secret-token"))
|
||||||
|
|
||||||
|
// ::add-mask:: appends to the same slice
|
||||||
|
job = append(job, "second mask")
|
||||||
|
assert.Equal(t, "*** and ***", mask(&job, "first mask and second mask"))
|
||||||
|
|
||||||
|
// a composite action brings its own slice
|
||||||
|
composite := []string{"composite mask"}
|
||||||
|
assert.Equal(t, "*** but first mask", mask(&composite, "composite mask but first mask"))
|
||||||
|
|
||||||
|
// and the job's masks still apply once it is back
|
||||||
|
assert.Equal(t, "*** and *** but composite mask", mask(&job, "first mask and second mask but composite mask"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
||||||
|
// A token with no character an escape would touch only gains its base64 forms:
|
||||||
|
// JSON, query and path escaping all leave it unchanged.
|
||||||
|
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"plaintoken", "***",
|
||||||
|
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
||||||
|
// The two shifted alignments, each without its leading and trailing group.
|
||||||
|
"YWludG9r", "***",
|
||||||
|
"bGFpbnRv", "***",
|
||||||
|
}, pairs)
|
||||||
|
|
||||||
|
// Too short to mask.
|
||||||
|
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||||
logger := logrus.New()
|
logger := logrus.New()
|
||||||
logger.Out = io.Discard
|
logger.Out = io.Discard
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actions-proto-go/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/actions-proto-go/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/actions-proto-go/pkg/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,15 +22,18 @@ 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.dev/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
|
"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/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,41 +207,81 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||||
|
func (rc *RunContext) toolCache(fallback string) string {
|
||||||
|
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
|
||||||
|
// and friends report so the two cannot drift apart.
|
||||||
|
func (rc *RunContext) runnerEnv(ctx context.Context) []string {
|
||||||
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
runnerContext := ext.GetRunnerContext(ctx)
|
||||||
|
runnerContext["tool_cache"] = rc.toolCache(container.DefaultToolCache)
|
||||||
|
|
||||||
|
env := make([]string, 0, len(runnerContext))
|
||||||
|
for key, value := range runnerContext {
|
||||||
|
env = append(env, fmt.Sprintf("RUNNER_%s=%s", strings.ToUpper(key), value))
|
||||||
|
}
|
||||||
|
slices.Sort(env)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitVolumes routes volume specs into binds and a source:target mount map, and returns the
|
||||||
|
// container paths they mount onto. Only a plain source:target volume fits the map, everything
|
||||||
|
// else (anonymous volumes, host binds, mount options) stays a bind.
|
||||||
|
func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) {
|
||||||
|
binds := []string{}
|
||||||
|
mounts := map[string]string{}
|
||||||
|
targets := map[string]bool{}
|
||||||
|
|
||||||
|
for _, spec := range specs {
|
||||||
|
parsed, err := loader.ParseVolume(spec)
|
||||||
|
if err != nil {
|
||||||
|
binds = append(binds, spec) // let Docker report the malformed spec
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets[parsed.Target] = true
|
||||||
|
if parsed.Type == string(mount.TypeVolume) && parsed.Source != "" && !parsed.ReadOnly {
|
||||||
|
mounts[parsed.Source] = parsed.Target
|
||||||
|
} else {
|
||||||
|
binds = append(binds, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return binds, mounts, targets
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
binds := []string{}
|
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
mounts := map[string]string{
|
var volumes []string
|
||||||
"act-toolcache": "/opt/hostedtoolcache",
|
|
||||||
name + "-env": ext.GetActPath(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if job := rc.Run.Job(); job != nil {
|
if job := rc.Run.Job(); job != nil {
|
||||||
if container := job.Container(); container != nil {
|
if container := job.Container(); container != nil {
|
||||||
for _, v := range container.Volumes {
|
for _, v := range container.Volumes {
|
||||||
if rc.ExprEval != nil {
|
if rc.ExprEval != nil {
|
||||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||||
}
|
}
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
volumes = append(volumes, v)
|
||||||
// Bind anonymous volume or host file.
|
|
||||||
binds = append(binds, v)
|
|
||||||
} else {
|
|
||||||
// Mount existing volume.
|
|
||||||
paths := strings.SplitN(v, ":", 2)
|
|
||||||
mounts[paths[0]] = paths[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// the runner's own mounts below yield to the targets the job claims
|
||||||
|
binds, mounts, claimed := splitVolumes(volumes)
|
||||||
|
|
||||||
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
|
}
|
||||||
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
|
mounts["act-toolcache"] = toolCache
|
||||||
|
}
|
||||||
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
@@ -247,9 +290,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if selinux.GetEnabled() {
|
if selinux.GetEnabled() {
|
||||||
bindModifiers = ":z"
|
bindModifiers = ":z"
|
||||||
}
|
}
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers))
|
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||||
} else {
|
} else {
|
||||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
mounts[name] = workdir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
@@ -283,7 +327,10 @@ 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 := filepath.Join(cacheDir, "tool_cache")
|
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
|
||||||
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
rc.JobContainer = &container.HostEnvironment{
|
rc.JobContainer = &container.HostEnvironment{
|
||||||
Path: path,
|
Path: path,
|
||||||
TmpDir: runnerTmp,
|
TmpDir: runnerTmp,
|
||||||
@@ -368,10 +415,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
@@ -392,7 +436,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// interpolate env
|
// interpolate env
|
||||||
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv))
|
||||||
|
// a service reaches the internet the way the job does; its own env still wins
|
||||||
|
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
||||||
for k, v := range spec.Env {
|
for k, v := range spec.Env {
|
||||||
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
|
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
|
||||||
}
|
}
|
||||||
@@ -672,13 +718,10 @@ func (rc *RunContext) ActionCacheDir() string {
|
|||||||
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
|
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
|
||||||
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
|
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
|
||||||
// per-directory AcquireCloneLock pattern).
|
// per-directory AcquireCloneLock pattern).
|
||||||
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
|
var jobMutexes lock.Keyed[*model.Job]
|
||||||
|
|
||||||
func lockJob(job *model.Job) func() {
|
func lockJob(job *model.Job) func() {
|
||||||
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
|
return jobMutexes.Lock(job)
|
||||||
mu := v.(*sync.Mutex)
|
|
||||||
mu.Lock()
|
|
||||||
return mu.Unlock
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) interpolateOutputs() common.Executor {
|
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||||
@@ -929,6 +972,20 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proxyBuildArgs returns the job's proxy variables as docker build args. The docker CLI
|
||||||
|
// pre-populates these from its own client configuration, but act builds through the API,
|
||||||
|
// so without them a Dockerfile action's RUN steps have no network behind a proxy.
|
||||||
|
func (rc *RunContext) proxyBuildArgs() map[string]*string {
|
||||||
|
if len(rc.Config.ProxyEnv) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := make(map[string]*string, len(rc.Config.ProxyEnv))
|
||||||
|
for name, value := range rc.Config.ProxyEnv {
|
||||||
|
args[name] = &value
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
func mergeMaps(maps ...map[string]string) map[string]string {
|
func mergeMaps(maps ...map[string]string) map[string]string {
|
||||||
rtnMap := make(map[string]string)
|
rtnMap := make(map[string]string)
|
||||||
for _, m := range maps {
|
for _, m := range maps {
|
||||||
@@ -992,6 +1049,8 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
|
|||||||
runnerContext := map[string]any{}
|
runnerContext := map[string]any{}
|
||||||
if rc.JobContainer != nil {
|
if rc.JobContainer != nil {
|
||||||
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
|
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
|
||||||
|
defaultToolCache, _ := runnerContext["tool_cache"].(string)
|
||||||
|
runnerContext["tool_cache"] = rc.toolCache(defaultToolCache)
|
||||||
}
|
}
|
||||||
runnerContext["name"] = rc.Config.RunnerName
|
runnerContext["name"] = rc.Config.RunnerName
|
||||||
runnerContext["environment"] = "self-hosted"
|
runnerContext["environment"] = "self-hosted"
|
||||||
@@ -1096,12 +1155,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()
|
||||||
@@ -1363,24 +1422,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
|||||||
|
|
||||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
binds := []string{}
|
binds, mounts, claimed := splitVolumes(svcVolumes)
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mounts := map[string]string{}
|
|
||||||
|
|
||||||
for _, v := range svcVolumes {
|
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
|
||||||
// Bind anonymous volume or host file.
|
|
||||||
binds = append(binds, v)
|
|
||||||
} else {
|
|
||||||
// Mount existing volume.
|
|
||||||
paths := strings.SplitN(v, ":", 2)
|
|
||||||
mounts[paths[0]] = paths[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,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/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
|
"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"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
@@ -291,6 +292,83 @@ jobs:
|
|||||||
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A service container reaches the internet the same way the job does, so it inherits the
|
||||||
|
// job's proxy; a service that sets the variable itself keeps its own value.
|
||||||
|
func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
name: test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: registry.example/job:latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
db:
|
||||||
|
image: postgres:latest
|
||||||
|
env:
|
||||||
|
no_proxy: db-only.example
|
||||||
|
steps: []
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var inputs []*container.NewContainerInput
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
inputs = append(inputs, input)
|
||||||
|
return fakeContainer{}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newContainer = origNewContainer })
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Name: "test",
|
||||||
|
Config: &Config{
|
||||||
|
Workdir: "/tmp",
|
||||||
|
ContainerNetworkMode: "host",
|
||||||
|
ReuseContainers: true,
|
||||||
|
Env: map[string]string{},
|
||||||
|
ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"},
|
||||||
|
Secrets: map[string]string{},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "job",
|
||||||
|
Workflow: workflow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
|
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||||
|
|
||||||
|
env := map[string][]string{}
|
||||||
|
for _, in := range inputs {
|
||||||
|
env[in.Image] = in.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128")
|
||||||
|
require.Contains(t, env["redis:latest"], "no_proxy=internal.example")
|
||||||
|
// the service's own env wins over what the runner injected, without dropping the rest
|
||||||
|
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example")
|
||||||
|
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example")
|
||||||
|
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128")
|
||||||
|
}
|
||||||
|
|
||||||
|
// act builds Dockerfile actions through the API, which does not pre-populate the proxy
|
||||||
|
// build args the docker CLI would, so the RUN steps would have no network behind a proxy.
|
||||||
|
func TestProxyBuildArgs(t *testing.T) {
|
||||||
|
rc := &RunContext{Config: &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128"}}}
|
||||||
|
|
||||||
|
args := rc.proxyBuildArgs()
|
||||||
|
|
||||||
|
require.Len(t, args, 1)
|
||||||
|
require.Equal(t, "http://proxy:3128", *args["http_proxy"])
|
||||||
|
|
||||||
|
// a job without a proxy builds exactly as it does today
|
||||||
|
require.Nil(t, (&RunContext{Config: &Config{}}).proxyBuildArgs())
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||||
rctemplate := &RunContext{
|
rctemplate := &RunContext{
|
||||||
Name: "TestRCName",
|
Name: "TestRCName",
|
||||||
@@ -363,6 +441,10 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
||||||
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
||||||
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
||||||
|
{"MountExistingVolumeReadOnly", []string{"volume-id:/volume:ro"}, "volume-id:/volume:ro", map[string]string{}},
|
||||||
|
{"BindRelativeHostPath", []string{"./relative:/volume"}, "./relative:/volume", map[string]string{}},
|
||||||
|
{"OverridesToolCache", []string{"/host/tools:/opt/hostedtoolcache"}, "/host/tools:/opt/hostedtoolcache", map[string]string{}},
|
||||||
|
{"OverridesDockerSocket", []string{"/host/docker.sock:/var/run/docker.sock"}, "/host/docker.sock:/var/run/docker.sock", map[string]string{}},
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||||
@@ -418,7 +500,14 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
jobBinds, jobMounts := rc.GetBindsAndMounts()
|
||||||
|
svcBinds, svcMounts := rc.GetServiceBindsAndMounts(testcase.volumes)
|
||||||
|
// job and service containers classify volumes alike, only their own mounts differ
|
||||||
|
for _, got := range []struct {
|
||||||
|
binds []string
|
||||||
|
mounts map[string]string
|
||||||
|
}{{jobBinds, jobMounts}, {svcBinds, svcMounts}} {
|
||||||
|
gotbind, gotmount := got.binds, got.mounts
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -428,6 +517,21 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
assert.Contains(t, gotmount, k)
|
assert.Contains(t, gotmount, k)
|
||||||
assert.Equal(t, gotmount[k], v)
|
assert.Equal(t, gotmount[k], v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker rejects a container with two mounts on one target, so the job's own
|
||||||
|
// volumes must displace the runner's rather than pile up next to them.
|
||||||
|
targets := map[string]bool{}
|
||||||
|
for _, bind := range gotbind {
|
||||||
|
parsed, err := loader.ParseVolume(bind)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
|
||||||
|
targets[parsed.Target] = true
|
||||||
|
}
|
||||||
|
for source, target := range gotmount {
|
||||||
|
assert.NotContains(t, targets, target, "%s mounts an already mounted target", source)
|
||||||
|
targets[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/actions-proto-go/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"
|
||||||
)
|
)
|
||||||
@@ -73,6 +73,7 @@ type Config struct {
|
|||||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
@@ -93,6 +94,8 @@ type Config struct {
|
|||||||
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
|
||||||
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
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMaxParallelConfig tests that MaxParallel config is properly set
|
|
||||||
func TestMaxParallelConfig(t *testing.T) {
|
|
||||||
t.Run("MaxParallel set to 2", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
MaxParallel: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
// Verify config is properly stored
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 2, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel set to 0 (no limit)", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
MaxParallel: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel not set (defaults to 0)", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelConcurrencyTracking tests that max-parallel actually limits concurrent execution
|
|
||||||
func TestMaxParallelConcurrencyTracking(t *testing.T) {
|
|
||||||
// This is a unit test for the parallel executor logic
|
|
||||||
// We test that when MaxParallel is set, it limits the number of workers
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
|
||||||
var maxConcurrent int
|
|
||||||
var currentConcurrent int
|
|
||||||
|
|
||||||
// Create a function that tracks concurrent execution
|
|
||||||
trackingFunc := func() {
|
|
||||||
mu.Lock()
|
|
||||||
currentConcurrent++
|
|
||||||
if currentConcurrent > maxConcurrent {
|
|
||||||
maxConcurrent = currentConcurrent
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
// Simulate work
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
currentConcurrent--
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run multiple tasks with limited parallelism
|
|
||||||
maxConcurrent = 0
|
|
||||||
currentConcurrent = 0
|
|
||||||
|
|
||||||
// This simulates what NewParallelExecutor does with a semaphore
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
semaphore := make(chan struct{}, 2) // Limit to 2 concurrent
|
|
||||||
|
|
||||||
for range 6 {
|
|
||||||
wg.Go(func() {
|
|
||||||
semaphore <- struct{}{} // Acquire
|
|
||||||
defer func() { <-semaphore }() // Release
|
|
||||||
trackingFunc()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// With a semaphore of 2, max concurrent should be <= 2
|
|
||||||
assert.LessOrEqual(t, maxConcurrent, 2, "Maximum concurrent executions should not exceed limit")
|
|
||||||
assert.GreaterOrEqual(t, maxConcurrent, 1, "Should have at least 1 concurrent execution")
|
|
||||||
}
|
|
||||||
@@ -13,13 +13,14 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/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"
|
||||||
@@ -163,6 +164,12 @@ func TestGraphEvent(t *testing.T) {
|
|||||||
assert.Empty(t, plan.Stages)
|
assert.Empty(t, plan.Stages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
|
||||||
|
var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"}
|
||||||
|
|
||||||
|
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
||||||
|
var planSlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
type TestJobFileInfo struct {
|
type TestJobFileInfo struct {
|
||||||
workdir string
|
workdir string
|
||||||
workflowPath string
|
workflowPath string
|
||||||
@@ -187,7 +194,14 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventName: j.eventName,
|
EventName: j.eventName,
|
||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
Platforms: j.platforms,
|
||||||
|
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||||
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
|
// as the shipped runner does, else a fixture asserting a job failure keeps its
|
||||||
|
// container, and its network, on the daemon forever
|
||||||
|
AutoRemove: true,
|
||||||
|
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||||
|
MaxParallel: 2,
|
||||||
ForceRebuild: true,
|
ForceRebuild: true,
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
@@ -210,7 +224,11 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
plan, err := planner.PlanEvent(j.eventName)
|
plan, err := planner.PlanEvent(j.eventName)
|
||||||
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
if err == nil && plan != nil {
|
if err == nil && plan != nil {
|
||||||
err = runner.NewPlanExecutor(plan)(ctx)
|
err = func() error {
|
||||||
|
planSlots <- struct{}{}
|
||||||
|
defer func() { <-planSlots }()
|
||||||
|
return runner.NewPlanExecutor(plan)(ctx)
|
||||||
|
}()
|
||||||
if j.errorMessage == "" {
|
if j.errorMessage == "" {
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
} else {
|
} else {
|
||||||
@@ -227,6 +245,7 @@ type TestConfig struct {
|
|||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -293,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},
|
||||||
@@ -315,6 +334,9 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// host /proc bind mounts are Linux-Docker-only
|
// host /proc bind mounts are Linux-Docker-only
|
||||||
requireLinuxDocker(t)
|
requireLinuxDocker(t)
|
||||||
}
|
}
|
||||||
|
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
||||||
|
t.Parallel()
|
||||||
|
}
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
@@ -445,6 +467,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
// Dryrun plans without containers or network (shells and local actions only).
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
@@ -464,6 +487,7 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -474,33 +498,11 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
// workflow's outputs via `needs`).
|
// workflow's outputs via `needs`).
|
||||||
func TestReusableWorkflowCaller(t *testing.T) {
|
func TestReusableWorkflowCaller(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
requireNetwork(t) // force-pulls a docker action image
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
config := &Config{
|
|
||||||
ForcePull: true,
|
|
||||||
ForceRebuild: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
|
||||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
|
||||||
{workdir, "local-action-via-composite-dockerfile", "push", "", platforms, secrets},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range tables {
|
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
|
||||||
table.runTest(ctx, t, config)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type maskJobLoggerFactory struct {
|
type maskJobLoggerFactory struct {
|
||||||
Output bytes.Buffer
|
Output bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -513,6 +515,7 @@ func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskValues(t *testing.T) {
|
func TestMaskValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
found := strings.Contains(text, "composite secret")
|
found := strings.Contains(text, "composite secret")
|
||||||
if found {
|
if found {
|
||||||
@@ -543,6 +546,7 @@ func TestMaskValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
workflowPath := "secrets"
|
workflowPath := "secrets"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -598,6 +602,7 @@ func TestRunWithService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
func TestRunActionInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "input-from-cli"
|
workflowPath := "input-from-cli"
|
||||||
|
|
||||||
@@ -617,6 +622,7 @@ func TestRunActionInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
|
||||||
workflowPath := "pull-request"
|
workflowPath := "pull-request"
|
||||||
@@ -633,6 +639,7 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
workflowPath := "matrix-with-user-inclusions"
|
||||||
|
|
||||||
|
|||||||
@@ -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/actions-proto-go/pkg/exprparser"
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type step interface {
|
type step interface {
|
||||||
|
|||||||
@@ -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/actions-proto-go/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/actions-proto-go/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/actions-proto-go/pkg/model"
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Actions served from the action cache are read out of a git object store rather than a
|
||||||
|
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
||||||
if sar.RunContext.Config.ActionCache != nil {
|
if sar.RunContext.Config.ActionCache != nil {
|
||||||
cache := sar.RunContext.Config.ActionCache
|
cache := sar.RunContext.Config.ActionCache
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := sar.actionDir()
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||||
@@ -138,9 +140,10 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
})
|
})
|
||||||
var ntErr common.Executor
|
var ntErr common.Executor
|
||||||
if err := gitClone(ctx); err != nil {
|
if err := gitClone(ctx); err != nil {
|
||||||
if errors.Is(err, git.ErrShortRef) {
|
var refErr *git.Error
|
||||||
|
if errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef) {
|
||||||
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||||
sar.Step.Uses, sar.remoteAction.Ref, err.(*git.Error).Commit())
|
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||||
} else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's
|
} else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -170,6 +173,9 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
sar.action = actionModel
|
sar.action = actionModel
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
// A stage of its own: it takes the same clone lock, and it has to land before
|
||||||
|
// runAction copies the action into the job container.
|
||||||
|
sar.patchActionToolkit,
|
||||||
)(ctx)
|
)(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ func (sar *stepActionRemote) pre() common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
sar.prepareActionExecutor(),
|
sar.prepareActionExecutor(),
|
||||||
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) main() common.Executor {
|
func (sar *stepActionRemote) main() common.Executor {
|
||||||
@@ -210,15 +216,51 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := sar.actionDir()
|
||||||
|
|
||||||
return sar.runAction(sar, actionDir, sar.remoteAction)(ctx)
|
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) post() common.Executor {
|
func (sar *stepActionRemote) post() common.Executor {
|
||||||
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
||||||
|
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
||||||
|
if sar.remoteAction == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
dir := sar.actionDir()
|
||||||
|
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
|
||||||
|
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
||||||
|
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||||
|
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
||||||
|
dir, scripts := sar.toolkitBundles()
|
||||||
|
patchToolkit(ctx, dir, scripts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
||||||
|
// runs it as shipped rather than repeating a failure the patch may have caused.
|
||||||
|
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
err := exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
dir, scripts := sar.toolkitBundles()
|
||||||
|
revertToolkit(ctx, dir, scripts)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sar *stepActionRemote) actionDir() string {
|
||||||
|
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||||
@@ -269,7 +311,7 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
|||||||
|
|
||||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||||
if sar.compositeRunContext == nil {
|
if sar.compositeRunContext == nil {
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := sar.actionDir()
|
||||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||||
|
|
||||||
|
|||||||
@@ -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/actions-proto-go/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"
|
||||||
|
|||||||
@@ -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/actions-proto-go/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,10 +110,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
|
|||||||
@@ -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/actions-proto-go/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/actions-proto-go/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/actions-proto-go/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,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/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/actions-proto-go/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/actions-proto-go/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/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"
|
||||||
|
|||||||
263
act/runner/toolkit_patch.go
Normal file
263
act/runner/toolkit_patch.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||||
|
// from working against Gitea. Both are edited out of the bundle the runner downloaded.
|
||||||
|
//
|
||||||
|
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
|
||||||
|
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
|
||||||
|
// which is why the stock upload-artifact aborts here. The edit empties the last of the three
|
||||||
|
// hostname tests, so `endsWith('.LOCALHOST')` becomes `endsWith(”)`, which every hostname
|
||||||
|
// satisfies: one string literal, no call sites to resolve, and the same answer the toolkit's own
|
||||||
|
// proposed ACTIONS_VENDOR switch would give. Gitea already makes this edit by hand in its fork
|
||||||
|
// of upload-artifact.
|
||||||
|
//
|
||||||
|
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||||
|
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
||||||
|
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||||
|
//
|
||||||
|
// Either of these landing upstream makes this file deletable:
|
||||||
|
//
|
||||||
|
// https://github.com/actions/toolkit/pull/2123 — an ACTIONS_VENDOR switch, naming Gitea
|
||||||
|
// https://github.com/actions/toolkit/issues/2439 — treat ACTIONS_RESULTS_URL as the signal
|
||||||
|
const (
|
||||||
|
CacheServiceV2Env = "ACTIONS_CACHE_SERVICE_V2"
|
||||||
|
cacheURLEnv = "ACTIONS_CACHE_URL"
|
||||||
|
resultsURLEnv = "ACTIONS_RESULTS_URL"
|
||||||
|
|
||||||
|
// localhostHost is the suffix isGhes accepts; emptying the test is what opens the gate,
|
||||||
|
// because every hostname ends with the empty string.
|
||||||
|
localhostHost = ".LOCALHOST"
|
||||||
|
|
||||||
|
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
|
||||||
|
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
|
||||||
|
// runner has not looked at, and is left alone.
|
||||||
|
artifactRefusal = "GHESNotSupportedError"
|
||||||
|
|
||||||
|
// sidecarSuffix names the directory of untouched copies, a sibling of the action directory
|
||||||
|
// because that directory is copied wholesale into job containers.
|
||||||
|
sidecarSuffix = ".toolkit-patch"
|
||||||
|
|
||||||
|
// skipMarker in the sidecar means a patched bundle already failed once here.
|
||||||
|
skipMarker = "skip"
|
||||||
|
|
||||||
|
maxBundleSize = 64 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// localhostTest matches the third hostname test of isGhes, in any quoting. The match is case
|
||||||
|
// sensitive on purpose, and that is load-bearing: isGhes uppercases the hostname before
|
||||||
|
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
|
||||||
|
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
|
||||||
|
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
|
||||||
|
// covering every major version of sixteen actions.
|
||||||
|
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
|
||||||
|
|
||||||
|
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
|
||||||
|
// the cache URL and falls back to the results URL, the v2 branch just below reads the results
|
||||||
|
// URL alone. That `||` pairing is the only place the two variables are read together, so
|
||||||
|
// matching them as one expression is what keeps the edit inside this function rather than
|
||||||
|
// anywhere they happen to sit near each other. The branches are 21 bytes apart minified and
|
||||||
|
// 63 not, across every bundle measured.
|
||||||
|
serviceURLBranches = regexp.MustCompile(`(` + envRead(cacheURLEnv) + `\s*\|\|\s*)(` +
|
||||||
|
envRead(resultsURLEnv) + `)((?s).{0,256}?)(` + envRead(resultsURLEnv) + `)`)
|
||||||
|
|
||||||
|
// cacheURLFirst gives both reads the preference the v1 branch already had.
|
||||||
|
cacheURLFirst = []byte(`${1}(process.env.` + cacheURLEnv + `||${2})${3}(process.env.` + cacheURLEnv + `||${4})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func envRead(name string) string {
|
||||||
|
return `process\s*\.\s*env\s*(?:\.\s*` + name + `\b|\[\s*` + quoted(name) + `\s*\])`
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoted matches a string literal in any of the three quote characters. RE2 has no
|
||||||
|
// backreferences, so the pairs are spelled out.
|
||||||
|
func quoted(pattern string) string {
|
||||||
|
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only
|
||||||
|
// remote actions get here: a local one lives in the user's checkout, which the runner does not
|
||||||
|
// rewrite.
|
||||||
|
func actionScriptPaths(dir string, action *model.Action) []string {
|
||||||
|
if action == nil || !action.Runs.Using.IsNode() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
|
||||||
|
if script != "" {
|
||||||
|
paths = append(paths, filepath.Join(dir, script))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchToolkit edits the toolkit in an action's bundles, keeping each original beside them. Every
|
||||||
|
// failure is silent and leaves the bundle as it was, which costs the cache client the v2 API and
|
||||||
|
// an artifact action nothing at all.
|
||||||
|
func patchToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||||
|
if len(scripts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(sidecarDir(actionDir), skipMarker)); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
|
|
||||||
|
for _, script := range scripts {
|
||||||
|
if err := patchBundle(script, originalFor(actionDir, script)); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("actions toolkit: %s left unpatched: %v", filepath.Base(script), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// revertToolkit puts the originals back and stops this action being patched again, so the next job
|
||||||
|
// runs it exactly as shipped. Called when a step failed with a patched bundle; it does not re-run
|
||||||
|
// the step, because a step's outputs and env-file writes are already recorded by then.
|
||||||
|
func revertToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||||
|
if len(scripts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sidecarDir(actionDir)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
|
|
||||||
|
reverted := false
|
||||||
|
for _, script := range scripts {
|
||||||
|
original := originalFor(actionDir, script)
|
||||||
|
if !isPatchOf(original, script) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Rename(original, script); err == nil {
|
||||||
|
reverted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reverted {
|
||||||
|
_ = os.WriteFile(filepath.Join(sidecarDir(actionDir), skipMarker), nil, 0o600)
|
||||||
|
common.Logger(ctx).Warnf("actions toolkit: restored the original %s, it will not be patched again", filepath.Base(actionDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidecarDir holds an action's untouched bundles, and the marker that stops it being patched.
|
||||||
|
func sidecarDir(actionDir string) string {
|
||||||
|
return actionDir + sidecarSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// originalFor is where a script's untouched copy lives, or "" for a script the action's own
|
||||||
|
// `runs` keys placed outside its directory, which is not this runner's to rewrite.
|
||||||
|
func originalFor(actionDir, script string) string {
|
||||||
|
rel, err := filepath.Rel(actionDir, script)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(sidecarDir(actionDir), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchBundle rewrites one entrypoint in place. The untouched copy kept beside it is what marks
|
||||||
|
// the bundle as already patched.
|
||||||
|
func patchBundle(script, original string) error {
|
||||||
|
if original == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(original); err == nil {
|
||||||
|
if isPatchOf(original, script) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The action's ref moved and git checked the new bundle out over the patched one, so
|
||||||
|
// the pair no longer belongs together. Patch afresh rather than keep an original that
|
||||||
|
// would restore an older version of the action.
|
||||||
|
if err := os.Remove(original); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() > maxBundleSize {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
patched, ok := patchedBundle(data)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(original), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The copy is taken before the bundle is replaced, so a write that fails part way can put the
|
||||||
|
// action back as it was. A crash needs no handling: the clone executor checks the action out
|
||||||
|
// and hard resets it on every prepare, so a half-written bundle never outlives the job.
|
||||||
|
if err := os.WriteFile(original, data, info.Mode().Perm()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(script, patched, info.Mode().Perm()); err != nil {
|
||||||
|
_ = os.Rename(original, script)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPatchOf reports whether script is exactly what patching original produced. It is what proves
|
||||||
|
// the two still belong together: an action whose ref moved is checked out over the patched bundle,
|
||||||
|
// leaving an original that would restore the version before the move.
|
||||||
|
func isPatchOf(original, script string) bool {
|
||||||
|
data, err := os.ReadFile(original)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(script)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
patched, ok := patchedBundle(data)
|
||||||
|
return ok && bytes.Equal(patched, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||||
|
// service at the cache server. A bundle this runner cannot account for comes back untouched.
|
||||||
|
func patchedBundle(data []byte) ([]byte, bool) {
|
||||||
|
if !localhostTest.Match(data) {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case bytes.Contains(data, []byte(CacheServiceV2Env)):
|
||||||
|
// The cache toolkit: both edits or neither, because choosing v2 without redirecting the
|
||||||
|
// URL would send the client to a results URL that serves no cache service.
|
||||||
|
if !serviceURLBranches.Match(data) {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
case bytes.Contains(data, []byte(artifactRefusal)):
|
||||||
|
// The artifact toolkit, where the gate is a plain refusal and there is no URL to move:
|
||||||
|
// artifacts already go to Gitea, which implements that service.
|
||||||
|
default:
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
|
||||||
|
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
|
||||||
|
// Drop the hostname from the test rather than rewriting the call, so the bundle's own
|
||||||
|
// quoting survives and the result stays valid even inside a string literal.
|
||||||
|
return bytes.Replace(test, []byte(localhostHost), nil, 1)
|
||||||
|
})
|
||||||
|
return serviceURLBranches.ReplaceAll(opened, cacheURLFirst), true
|
||||||
|
}
|
||||||
196
act/runner/toolkit_patch_e2e_test.go
Normal file
196
act/runner/toolkit_patch_e2e_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actionsCacheRef pins the actions/cache release this is verified against. Bump it
|
||||||
|
// deliberately: a new release is exactly what can stop the patch matching.
|
||||||
|
const actionsCacheRef = "v6.1.0"
|
||||||
|
|
||||||
|
// bundleFromGitHub downloads one entrypoint, keeping it in the user cache dir so repeated runs
|
||||||
|
// cost nothing. The bundles are megabytes, too large to vendor.
|
||||||
|
func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cacheDir, err := os.UserCacheDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir := filepath.Join(cacheDir, "gitea-runner-test", strings.ReplaceAll(repo, "/", "-")+"-"+ref)
|
||||||
|
bundle := filepath.Join(dir, strings.ReplaceAll(path, "/", "-"))
|
||||||
|
if _, err := os.Stat(bundle); err == nil {
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
|
||||||
|
url := "https://raw.githubusercontent.com/" + repo + "/" + ref + "/" + path
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("cannot reach %s: %v", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Skipf("GET %s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
file, err := os.Create(bundle)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
require.NoError(t, file.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
||||||
|
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
||||||
|
// so only the patch can make the client choose v2 and find the cache server.
|
||||||
|
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
state := filepath.Join(runnerTemp, "state")
|
||||||
|
output := filepath.Join(runnerTemp, "output")
|
||||||
|
for _, name := range []string{state, output} {
|
||||||
|
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(t.Context(), "node", script)
|
||||||
|
cmd.Dir = workspace
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"INPUT_PATH=to-cache",
|
||||||
|
"INPUT_KEY="+key,
|
||||||
|
"ACTIONS_RUNTIME_TOKEN="+token,
|
||||||
|
"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",
|
||||||
|
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||||
|
"GITHUB_REF=refs/heads/main",
|
||||||
|
"GITHUB_EVENT_NAME=push",
|
||||||
|
"GITHUB_WORKSPACE="+workspace,
|
||||||
|
"RUNNER_TEMP="+runnerTemp,
|
||||||
|
"GITHUB_STATE="+state,
|
||||||
|
"GITHUB_OUTPUT="+output,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
||||||
|
require.NoError(t, err, "%s failed", script)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func tempDirPath(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the
|
||||||
|
// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it
|
||||||
|
// save and restore through this runner's cache server. The unreachable results URL is the point,
|
||||||
|
// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release
|
||||||
|
// stops matching the patch the client falls back to v1 and this fails on the version line, which
|
||||||
|
// is the signal to look at the new bundle.
|
||||||
|
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
// A stand-in action directory, patched exactly as a downloaded one would be.
|
||||||
|
actionDir := tempDirPath(t)
|
||||||
|
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)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||||
|
handler.RegisterJob(token, repo)
|
||||||
|
|
||||||
|
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
||||||
|
content := []byte("cached through the patched gate")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
||||||
|
|
||||||
|
const key = "patched-gate-key"
|
||||||
|
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||||
|
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)
|
||||||
|
|
||||||
|
saved := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||||
|
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||||
|
|
||||||
|
restored := tempDirPath(t)
|
||||||
|
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
||||||
|
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, content, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||||
|
// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is
|
||||||
|
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
||||||
|
// 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.
|
||||||
|
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ repo, ref, path string }{
|
||||||
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
||||||
|
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
||||||
|
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
||||||
|
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
||||||
|
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
||||||
|
// 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
|
||||||
|
// releases before it refuse in a shape the runner leaves alone.
|
||||||
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js"},
|
||||||
|
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
||||||
|
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
||||||
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(bundleFromGitHub(t, tc.repo, tc.ref, tc.path))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out, patched := patchedBundle(data)
|
||||||
|
assert.True(t, patched, "the version gate was not patched")
|
||||||
|
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), CacheServiceV2Env) {
|
||||||
|
return // the artifact toolkit: a refusal to open, and no URL to move
|
||||||
|
}
|
||||||
|
// Only the reads inside getCacheServiceURL are rewritten. The others, such as the
|
||||||
|
// feature-availability check, must be left as they are.
|
||||||
|
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
|
||||||
|
"the cache service URL was not redirected")
|
||||||
|
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
|
||||||
|
"a read of the results URL was lost, it must stay as the fallback")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
327
act/runner/toolkit_patch_test.go
Normal file
327
act/runner/toolkit_patch_test.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/actions-proto-go/pkg/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The three shapes real bundlers emit, reduced to the bytes that matter: the version gate, and
|
||||||
|
// the URL getter that follows it. tsc keeps the names, webpack prefixes them, esbuild mangles
|
||||||
|
// them, writes ternaries in place of the switch, and records the real name in the export
|
||||||
|
// assignment. Each carries both reads of the results URL, as the real getter does.
|
||||||
|
const (
|
||||||
|
urlTSC = `function getCacheServiceURL() {` + "\n" + ` switch (getCacheServiceVersion()) {` + "\n" + ` case 'v1':` + "\n" + ` return (process.env['ACTIONS_CACHE_URL'] || process.env['ACTIONS_RESULTS_URL'] || '');` + "\n" + ` case 'v2':` + "\n" + ` return process.env['ACTIONS_RESULTS_URL'] || '';` + "\n" + ` }` + "\n" + `}`
|
||||||
|
urlEsbuild = `function YK(){let e=XK();return e==="v1"?process.env.ACTIONS_CACHE_URL||process.env.ACTIONS_RESULTS_URL||"":e==="v2"?process.env.ACTIONS_RESULTS_URL||"":""}`
|
||||||
|
|
||||||
|
isGhesTSC = `function isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}`
|
||||||
|
gateTSC = isGhesTSC + "\n" + `function getCacheServiceVersion() {` + "\n" + ` if (isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||||
|
gateWebpack = `function config_isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}` + "\n" + `function config_getCacheServiceVersion() {` + "\n" + ` if (config_isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
||||||
|
gateEsbuild = `vu.isGhes=$K;vu.getCacheServiceVersion=XK;function $K(){let e=new URL(process.env.GITHUB_SERVER_URL||"https://github.com").hostname.toUpperCase(),r=e==="GITHUB.COM",n=e.endsWith(".GHE.COM"),i=e.endsWith(".LOCALHOST");return!r&&!n&&!i}function XK(){return $K()?"v1":process.env.ACTIONS_CACHE_SERVICE_V2?"v2":"v1"}` + urlEsbuild
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPatchedBundle(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
wantPatched bool
|
||||||
|
}{
|
||||||
|
{"tsc keeps the names", gateTSC, true},
|
||||||
|
{"webpack prefixes them", gateWebpack, true},
|
||||||
|
{"esbuild mangles and minifies them", gateEsbuild, true},
|
||||||
|
// A bundler picks its own quoting; gateTSC is single-quoted already.
|
||||||
|
{"double-quoted", requoted(`"`), true},
|
||||||
|
{"backtick-quoted", requoted("`"), true},
|
||||||
|
// sccache-action sets the variable itself; there is no gate to open.
|
||||||
|
{"mentions the variable without the gate", `core.exportVariable("ACTIONS_CACHE_SERVICE_V2","on")`, false},
|
||||||
|
// Both edits or neither: a gate patched without the URL would send the client to a
|
||||||
|
// results URL that serves no cache service.
|
||||||
|
{"gate without a recognisable url getter", strings.TrimSuffix(gateTSC, "\n"+urlTSC), false},
|
||||||
|
// And the other way round: an action that reads both variables but has no gate to open.
|
||||||
|
{"url getter without a gate", urlTSC, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, patched := patchedBundle([]byte(tc.body))
|
||||||
|
assert.Equal(t, tc.wantPatched, patched)
|
||||||
|
if !tc.wantPatched {
|
||||||
|
assert.Equal(t, tc.body, string(out), "an unpatched bundle must come back byte for byte")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.True(t, gateOpened(string(out)))
|
||||||
|
// The other two hostname tests are left alone, so a host that really is GitHub or
|
||||||
|
// GHES is still recognised as such.
|
||||||
|
assert.NotContains(t, string(out), ".LOCALHOST", "the localhost test is the one that opens")
|
||||||
|
assert.Contains(t, string(out), ".GHE.COM")
|
||||||
|
|
||||||
|
// Every read of the results URL now prefers the cache URL, and none was lost: the
|
||||||
|
// results URL stays the fallback, so a runner not serving the cache still works.
|
||||||
|
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"), strings.Count(string(out), "ACTIONS_RESULTS_URL"))
|
||||||
|
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"),
|
||||||
|
strings.Count(string(out), "(process.env.ACTIONS_CACHE_URL||process.env"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// undici, bundled into every one of these actions, decides whether to trust a URL with a
|
||||||
|
// lowercase test that reads almost the same. Opening it would tell the HTTP client that every URL
|
||||||
|
// is trustworthy, so the uppercase the toolkit produces is what separates them.
|
||||||
|
func TestPatchedBundleLeavesTrustworthyURLCheckAlone(t *testing.T) {
|
||||||
|
const undici = `if(n.hostname==="localhost"||n.hostname.includes("localhost.")||n.hostname.endsWith(".localhost")){return true}`
|
||||||
|
|
||||||
|
out, patched := patchedBundle([]byte(undici + gateTSC))
|
||||||
|
require.True(t, patched)
|
||||||
|
assert.Contains(t, string(out), undici, "the trustworthy-URL check must survive byte for byte")
|
||||||
|
assert.True(t, gateOpened(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so
|
||||||
|
// opening it is what lets the stock upload-artifact work against Gitea instead of aborting.
|
||||||
|
func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) {
|
||||||
|
const artifact = isGhesTSC + "\n" + `uploadArtifact(){if(isGhes()){throw new GHESNotSupportedError()}}`
|
||||||
|
|
||||||
|
out, patched := patchedBundle([]byte(artifact))
|
||||||
|
assert.True(t, patched)
|
||||||
|
assert.True(t, gateOpened(string(out)))
|
||||||
|
assert.Contains(t, string(out), "GHESNotSupportedError", "the refusal itself is left in place, it just stops firing")
|
||||||
|
|
||||||
|
// A bundle using the gate for something this runner has not accounted for is not touched.
|
||||||
|
unknown := strings.Replace(artifact, "GHESNotSupportedError", "SomeOtherError", 1)
|
||||||
|
out, patched = patchedBundle([]byte(unknown))
|
||||||
|
assert.False(t, patched)
|
||||||
|
assert.Equal(t, unknown, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requoted respells gateTSC's string literals with another quote character.
|
||||||
|
func requoted(quote string) string {
|
||||||
|
gate := strings.ReplaceAll(gateTSC, `'.LOCALHOST'`, quote+".LOCALHOST"+quote)
|
||||||
|
gate = strings.ReplaceAll(gate, `['ACTIONS_RESULTS_URL']`, "["+quote+"ACTIONS_RESULTS_URL"+quote+"]")
|
||||||
|
return strings.ReplaceAll(gate, `['ACTIONS_CACHE_URL']`, "["+quote+"ACTIONS_CACHE_URL"+quote+"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateOpened reports whether the hostname test was emptied, in whatever quoting the bundle used.
|
||||||
|
func gateOpened(body string) bool {
|
||||||
|
return strings.Contains(body, "endsWith(") && !strings.Contains(body, ".LOCALHOST")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The patched bundle must still be JavaScript, and must resolve the way the runner needs: v2 for
|
||||||
|
// an ordinary Gitea host, the cache server for the service URL, and the results URL when there is
|
||||||
|
// no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing.
|
||||||
|
func TestPatchedBundleBehavesInNode(t *testing.T) {
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
eval := func(t *testing.T, bundle, prelude, cacheURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
script := prelude + bundle + "\nprocess.stdout.write(getCacheServiceVersion()+' '+getCacheServiceURL())"
|
||||||
|
cmd := exec.CommandContext(t.Context(), "node", "-e", script)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||||
|
"ACTIONS_CACHE_URL="+cacheURL,
|
||||||
|
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||||
|
"GITHUB_SERVER_URL=https://gitea.example",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "%s", out)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct{ name, bundle, prelude string }{
|
||||||
|
{"tsc", gateTSC, ""},
|
||||||
|
{"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"},
|
||||||
|
{"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Unpatched, a Gitea host is taken for GHES: v1, whose branch already reads the
|
||||||
|
// cache URL. The patch has to move the version without moving that.
|
||||||
|
assert.Equal(t, "v1 http://cache:8088/", eval(t, tc.bundle, tc.prelude, "http://cache:8088/"))
|
||||||
|
|
||||||
|
patched, ok := patchedBundle([]byte(tc.bundle))
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
assert.Equal(t, "v2 http://cache:8088/", eval(t, string(patched), tc.prelude, "http://cache:8088/"))
|
||||||
|
assert.Equal(t, "v2 https://gitea.example", eval(t, string(patched), tc.prelude, ""),
|
||||||
|
"with no cache server the results URL is still the fallback")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bundler that embeds module sources as strings, such as webpack with devtool: eval, carries
|
||||||
|
// the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument
|
||||||
|
// would end that string early and leave the bundle unparseable.
|
||||||
|
func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
escaped := strings.ReplaceAll(gateTSC, `"`, `\"`)
|
||||||
|
embedded := `eval("` + strings.ReplaceAll(escaped, "\n", `\n`) + `");`
|
||||||
|
out, patched := patchedBundle([]byte(embedded))
|
||||||
|
require.True(t, patched)
|
||||||
|
|
||||||
|
file := filepath.Join(t.TempDir(), "bundle.js")
|
||||||
|
require.NoError(t, os.WriteFile(file, out, 0o600))
|
||||||
|
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
|
||||||
|
require.NoError(t, err, "%s", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatchBundleKeepsTheOriginal(t *testing.T) {
|
||||||
|
dir, script := bundleFile(t, gateTSC)
|
||||||
|
original := originalFor(dir, script)
|
||||||
|
|
||||||
|
require.NoError(t, patchBundle(script, original))
|
||||||
|
patched, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, gateOpened(string(patched)))
|
||||||
|
|
||||||
|
kept, err := os.ReadFile(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(kept), "the untouched bundle is kept outside the action tree")
|
||||||
|
assert.NotContains(t, original, dir+string(filepath.Separator), "originals must not ship into job containers")
|
||||||
|
|
||||||
|
// Patching again must not stack, and must not overwrite the kept original.
|
||||||
|
require.NoError(t, patchBundle(script, original))
|
||||||
|
again, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, string(patched), string(again))
|
||||||
|
kept, err = os.ReadFile(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(kept))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bundle with nothing to patch is left exactly as it was, with no original kept beside it.
|
||||||
|
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||||
|
dir, script := bundleFile(t, `console.log("checkout")`)
|
||||||
|
original := originalFor(dir, script)
|
||||||
|
|
||||||
|
require.NoError(t, patchBundle(script, original))
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||||
|
_, err = os.Stat(original)
|
||||||
|
assert.True(t, os.IsNotExist(err), "no original is kept for a bundle that was not patched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bundleFile writes one entrypoint into a fresh action directory.
|
||||||
|
func bundleFile(t *testing.T, body string) (dir, script string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir = t.TempDir()
|
||||||
|
script = filepath.Join(dir, "index.js")
|
||||||
|
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
||||||
|
return dir, script
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionScriptPaths(t *testing.T) {
|
||||||
|
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "dist/restore/index.js", Post: "dist/save/index.js"}}
|
||||||
|
assert.Equal(t, []string{"/a/dist/restore/index.js", "/a/dist/save/index.js"}, actionScriptPaths("/a", node))
|
||||||
|
|
||||||
|
// Only a node action has a bundle to patch.
|
||||||
|
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
||||||
|
assert.Nil(t, actionScriptPaths("/a", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A step that fails with a patched bundle gets the untouched bundle back, and the action is not
|
||||||
|
// patched again, so later jobs run it exactly as its author shipped it.
|
||||||
|
func TestRevertToolkit(t *testing.T) {
|
||||||
|
dir, script := bundleFile(t, gateTSC)
|
||||||
|
scripts := []string{script}
|
||||||
|
|
||||||
|
patchToolkit(t.Context(), dir, scripts)
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, gateOpened(string(body)), "precondition: the bundle is patched")
|
||||||
|
|
||||||
|
revertToolkit(t.Context(), dir, scripts)
|
||||||
|
body, err = os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(body), "the original bundle is back")
|
||||||
|
|
||||||
|
// The skip marker survives, so the action stays unpatched from now on.
|
||||||
|
patchToolkit(t.Context(), dir, scripts)
|
||||||
|
body, err = os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(body), "a reverted action stays unpatched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An action whose ref moves is checked out over the patched bundle. The kept original then
|
||||||
|
// belongs to the version before the move, and must not be restored over the new one.
|
||||||
|
func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
||||||
|
dir, script := bundleFile(t, gateTSC)
|
||||||
|
original := originalFor(dir, script)
|
||||||
|
scripts := []string{script}
|
||||||
|
|
||||||
|
patchToolkit(t.Context(), dir, scripts)
|
||||||
|
require.NoError(t, os.WriteFile(script, []byte(gateWebpack), 0o600)) // the new version lands
|
||||||
|
|
||||||
|
// Reverting must not roll the action back to the version the original came from.
|
||||||
|
revertToolkit(t.Context(), dir, scripts)
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateWebpack, string(body))
|
||||||
|
|
||||||
|
// Nothing was reverted, so the action is not marked off either: the new version is patched
|
||||||
|
// in its own right, and keeps its own original.
|
||||||
|
require.NoFileExists(t, filepath.Join(sidecarDir(dir), skipMarker))
|
||||||
|
require.NoError(t, patchBundle(script, original))
|
||||||
|
body, err = os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, gateOpened(string(body)))
|
||||||
|
kept, err := os.ReadFile(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateWebpack, string(kept))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
||||||
|
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||||
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Env: env,
|
||||||
|
Config: &Config{ActionCacheDir: t.TempDir()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||||
|
return sar, script
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
||||||
|
sar, script := newStep(t, map[string]string{})
|
||||||
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
||||||
|
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
||||||
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, gateOpened(string(body)))
|
||||||
|
|
||||||
|
failed := errors.New("the step failed")
|
||||||
|
require.ErrorIs(t, sar.revertToolkitOnFailure(func(context.Context) error { return failed })(t.Context()), failed)
|
||||||
|
|
||||||
|
body, err = os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
70
docs/job-hooks.md
Normal file
70
docs/job-hooks.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Job hooks
|
||||||
|
|
||||||
|
Job hooks are operator-provided scripts that run **inside the job environment**, before the job's first step and after its last one. They are the equivalent of GitHub's [job hooks](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts) and are configured under `runner.hooks` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
hooks:
|
||||||
|
job_started: /hooks/started.sh
|
||||||
|
job_completed: /hooks/completed.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Runs |
|
||||||
|
| --- | --- |
|
||||||
|
| `runner.hooks.job_started` | Before the job's first step, before any action is downloaded |
|
||||||
|
| `runner.hooks.job_completed` | After the job's last post step, while the job environment is still up |
|
||||||
|
|
||||||
|
`ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` are read from the runner's environment (`runner.envs`, `runner.env_file`) when the settings are unset, so a configuration carried over from actions/runner keeps working. The settings take precedence. A workflow cannot point the runner at a different hook: the variables are only read from the runner's own environment, never from the job's.
|
||||||
|
|
||||||
|
Both hooks are **synchronous** and block the job while they run, and a non-zero exit from either one fails the job. There is no `continue-on-error` and no per-hook timeout — the job's own `runner.timeout` is the only bound. The operator is responsible for the hook's resilience; run anything long in the background from within the hook.
|
||||||
|
|
||||||
|
## Where they run
|
||||||
|
|
||||||
|
The hooks run in the same place as the job's steps: inside the job container, or on the host in host mode. The paths are resolved *there*, so the script has to exist in the job image or on the host — a path that only exists on the runner host is not visible to a containerized job. For host-wide cleanup that runs after the job environment is gone, use the [post-task script](post-task-script.md) instead.
|
||||||
|
|
||||||
|
> This is a deliberate difference from actions/runner, which runs its job hooks on the host, outside any container the job declares. Running them where the steps run is what lets a hook prepare the environment the steps actually see.
|
||||||
|
|
||||||
|
The script is run according to its extension:
|
||||||
|
|
||||||
|
| Extension | Command |
|
||||||
|
| --- | --- |
|
||||||
|
| `.sh` | `bash -e <path>` |
|
||||||
|
| `.ps1` | `pwsh -command . '<path>'` |
|
||||||
|
| anything else | the file itself, which needs its own shebang and executable bit |
|
||||||
|
|
||||||
|
As on GitHub, the shell flags applied to `run:` steps are **not** applied to a hook — set `pipefail` or anything else you want inside the script.
|
||||||
|
|
||||||
|
### Docker-in-Docker and Docker-out-of-Docker
|
||||||
|
|
||||||
|
The hook is executed and its files are exchanged over the Docker API, addressed by container ID, so no path is translated between the runner and the daemon. Both setups work unchanged, but they differ in where the hook file has to be:
|
||||||
|
|
||||||
|
- **DinD** — the daemon has its own filesystem. Bake the hook into the job image; a path from the runner's filesystem is not visible to it.
|
||||||
|
- **DooD** — the job container is created by the host's daemon, so a bind mount in `container.options` is resolved against the **host**, not against the runner container. Either bake the hook into the job image, or mount a host directory and add it to `container.valid_volumes`.
|
||||||
|
|
||||||
|
A hook path that does not exist inside the job environment fails the job with `No such file or directory`, naming the path.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go.
|
||||||
|
|
||||||
|
Its stdout is part of the job log, inside a collapsible group, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job, `::set-env::` and `::add-path::` apply to the steps that follow.
|
||||||
|
|
||||||
|
`$GITHUB_ENV` and `$GITHUB_PATH` point at files that are read back after the hook exits, so the file-command form works too:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
echo "REGISTRY_TOKEN=$(fetch-token)" >> "$GITHUB_ENV"
|
||||||
|
echo "/opt/tooling/bin" >> "$GITHUB_PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both files are the hook's own, separate from the per-step ones, so nothing a hook writes is truncated by the first step.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- Keep hooks **fast** and return the right exit code: they are on the critical path of every job, and nothing bounds them.
|
||||||
|
- Use **idempotent** operations, and expect `job_completed` to run after success, failure, and cancellation alike.
|
||||||
|
- Mask anything secret the hook prints or exports with `::add-mask::`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Post-task script](post-task-script.md) — host-side cleanup after the job environment is torn down.
|
||||||
@@ -150,6 +150,7 @@ powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0po
|
|||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
- [Job hooks](job-hooks.md) — scripts running inside the job environment, around its steps
|
||||||
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
||||||
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
||||||
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user