mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-12 19:03:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f | ||
|
|
9e3647395a | ||
|
|
c158ac5472 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 | ||
|
|
b9018aca31 | ||
|
|
6c77065295 | ||
|
|
235c035003 | ||
|
|
12dc9d26a2 |
No files matched your search
@@ -74,7 +74,7 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ jobs:
|
|||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -96,13 +96,8 @@ go-check:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: ## check the Go code formatting
|
||||||
@diff=$$(git diff --color=always -- '*.go'); \
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
@@ -149,13 +144,8 @@ tidy: ## run go mod tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: ## check that go.mod and go.sum are tidy
|
||||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
$(GO) mod tidy -diff
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@@ -171,7 +161,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:6dac556d980b7f0e5498d08f08cee0ca67798b4ad6c23964a9214920e67758d0 # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_CONCURRENCY ?= 8
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|||||||
@@ -266,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
|
|||||||
|
|
||||||
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||||
|
|
||||||
|
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||||
|
|
||||||
|
Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||||
|
|
||||||
#### Proxy
|
#### Proxy
|
||||||
|
|
||||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
@@ -280,7 +291,7 @@ The runner uses them for its own requests and gives them to every job, in lower
|
|||||||
|
|
||||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
- the cache server
|
- the built-in cache server, whose address is assigned at startup
|
||||||
- `localhost`, `127.0.0.1` and `::1`
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
- the job's service containers
|
- the job's service containers
|
||||||
- the Docker daemon, when it is reached over `tcp://`
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
@@ -297,61 +308,44 @@ A password in a proxy URL is hidden in job logs. Any step can still read it, bec
|
|||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server, so runners do not share cached entries. When the runner itself runs in Docker, set `cache.host` to an address job containers can reach and `cache.port` to a fixed published port, or put jobs on a shared `container.network`.
|
||||||
|
|
||||||
|
**Sharing a cache between runners**
|
||||||
|
|
||||||
|
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# cache-server.yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "<secret>" # or external_secret_file: /path/to/secret
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-runner -c cache-server.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# each runner's config
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jobs connect to `external_server` too, so point it at the reverse proxy if one fronts the server. `--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
|
||||||
|
|
||||||
|
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
|
||||||
|
|
||||||
**Eviction**
|
**Eviction**
|
||||||
|
|
||||||
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled.
|
Entries not read or written within `retention` (default `168h`) are removed. A repository over `repo_size_limit` (default `10GB`) loses its least recently used entries, and `size_limit` (off by default) caps the whole cache the same way. Entries in use are never removed. The cache also keeps 1024 MiB free on its volume, or `health_check.min_free_disk_space_mb` when health checks are enabled. See [config.example.yaml](internal/pkg/config/config.example.yaml) for all options.
|
||||||
|
|
||||||
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
|
|
||||||
|
|
||||||
**Cache service v2**
|
**Cache service v2**
|
||||||
|
|
||||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
|
||||||
|
|
||||||
```yaml
|
With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
|
||||||
cache:
|
|
||||||
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 on its way into the job, undone whenever the action is downloaded again. 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, so it is made whatever `v2` says: that setting only governs the API the runner advertises. Set `runner.patch_actions: false` to leave every bundle exactly as shipped, an escape hatch for an action the edit breaks. The artifact actions then refuse again and the cache client keeps to v1.
|
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
|
||||||
|
|
||||||
1. Create a config file for the cache server host:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cache:
|
|
||||||
dir: /data/actcache
|
|
||||||
port: 8088
|
|
||||||
external_secret: "replace-with-a-strong-random-secret"
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gitea-runner -c cache-server-config.yaml cache-server
|
|
||||||
```
|
|
||||||
|
|
||||||
3. On every runner:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cache:
|
|
||||||
external_server: "http://<cache-server-host>:8088/"
|
|
||||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
Jobs reach the cache server at `external_server`, so when a reverse proxy fronts the server, point `external_server` at the proxy. The cache server itself needs no extra configuration.
|
|
||||||
|
|
||||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
|
||||||
|
|
||||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
|
||||||
|
|
||||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
|
||||||
|
|
||||||
#### Official Docker image
|
#### Official Docker image
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -234,7 +233,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
|
|
||||||
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", h.outboundIP, h.port)
|
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
@@ -603,12 +602,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
token := bearerToken(r)
|
token := bearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cred, ok := h.lookupCredential(token)
|
cred, ok := h.lookupCredential(token)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
@@ -617,6 +616,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||||
@@ -720,6 +727,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
|||||||
h.responseJSON(w, r, http.StatusOK)
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
|
||||||
|
func hashedToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
func bearerToken(r *http.Request) string {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
const prefix = "Bearer "
|
const prefix = "Bearer "
|
||||||
@@ -770,14 +783,9 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
if exact != nil {
|
if exact != nil {
|
||||||
return exact, nil
|
return exact, nil
|
||||||
}
|
}
|
||||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
|
||||||
re, err := regexp.Compile(prefixPattern)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := db.FindOne(cache,
|
if err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||||
And("Key").RegExp(re).
|
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(true).
|
And("Complete").Eq(true).
|
||||||
SortBy("CreatedAt").Reverse()); err != nil {
|
SortBy("CreatedAt").Reverse()); err != nil {
|
||||||
@@ -805,8 +813,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
}
|
}
|
||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
err := db.FindOne(cache,
|
err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Key").Eq(key).Index("Key").
|
||||||
And("Key").Eq(key).
|
And("Repo").Eq(repo).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(complete).
|
And("Complete").Eq(complete).
|
||||||
SortBy(sortBy).Reverse())
|
SortBy(sortBy).Reverse())
|
||||||
@@ -820,14 +828,16 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
return db.Bolt().Update(func(tx *bbolt.Tx) error {
|
||||||
|
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
|
||||||
return fmt.Errorf("insert cache: %w", err)
|
return fmt.Errorf("insert cache: %w", err)
|
||||||
}
|
}
|
||||||
// write back id to db
|
// write back id to db
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
|
||||||
return fmt.Errorf("write back id to db: %w", err)
|
return fmt.Errorf("write back id to db: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||||
@@ -852,6 +862,9 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
if requireIncomplete && cache.Complete {
|
if requireIncomplete && cache.Complete {
|
||||||
return fmt.Errorf("cache %d: already complete", id)
|
return fmt.Errorf("cache %d: already complete", id)
|
||||||
}
|
}
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
return db.Update(cache.ID, cache)
|
return db.Update(cache.ID, cache)
|
||||||
}
|
}
|
||||||
@@ -861,9 +874,11 @@ const (
|
|||||||
|
|
||||||
defaultSweepInterval = time.Hour
|
defaultSweepInterval = time.Hour
|
||||||
|
|
||||||
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
|
||||||
// for it, and no sweep cuts off a download in progress.
|
|
||||||
inUseGrace = artifactURLTTL
|
// inUseGrace covers artifactURLTTL plus the touchStale lag so an entry outlives every signed URL
|
||||||
|
// still usable for it, and no sweep cuts off a download in progress.
|
||||||
|
inUseGrace = artifactURLTTL + touchStale
|
||||||
|
|
||||||
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
||||||
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
||||||
@@ -957,7 +972,7 @@ func (h *Handler) evictExpired(db *bolthold.Store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
||||||
window := max(h.policy.Retention, inUseGrace)
|
window := max(h.policy.Retention+touchStale, inUseGrace)
|
||||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,10 +1073,18 @@ func inUse(cache *Cache) bool {
|
|||||||
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// touchNeeded skips a write that would not change the second, and completed entries fresher than touchStale.
|
||||||
|
func touchNeeded(cache *Cache) bool {
|
||||||
|
return cache.UsedAt < time.Now().Unix() && (!cache.Complete || time.Since(time.Unix(cache.UsedAt, 0)) >= touchStale)
|
||||||
|
}
|
||||||
|
|
||||||
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
||||||
// go through touchCache, which opens its own store and would block on the exclusive lock
|
// go through touchCache, which opens its own store and would block on the exclusive lock
|
||||||
// for as long as the caller holds one.
|
// for as long as the caller holds one.
|
||||||
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
h.logger.Warnf("touch cache: %v", err)
|
h.logger.Warnf("touch cache: %v", err)
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
@@ -767,7 +771,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
@@ -1042,6 +1046,26 @@ func TestHandler_gcCacheInterval(t *testing.T) {
|
|||||||
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
|
||||||
|
fresh := &Cache{Repo: testRepo, Key: "fresh", Version: "v", Complete: true, UsedAt: time.Now().Unix()}
|
||||||
|
pending := &Cache{Repo: testRepo, Key: "pending", Version: "v", UsedAt: time.Now().Add(-time.Second).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{}, fresh, pending)
|
||||||
|
txID := func() int {
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
var id int
|
||||||
|
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error { id = tx.ID(); return nil }))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
before := txID()
|
||||||
|
require.NoError(t, handler.touchCache(fresh.ID, false))
|
||||||
|
assert.Equal(t, before, txID())
|
||||||
|
require.NoError(t, handler.touchCache(pending.ID, false))
|
||||||
|
assert.Greater(t, txID(), before)
|
||||||
|
}
|
||||||
|
|
||||||
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
||||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ const (
|
|||||||
|
|
||||||
blobUploadURLTTL = time.Hour
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
// twirpInternal is the only error code that is not the client's fault.
|
|
||||||
twirpInternal = "internal"
|
twirpInternal = "internal"
|
||||||
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
@@ -56,7 +56,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Key == "" || req.Version == "" {
|
if req.Key == "" || req.Version == "" {
|
||||||
@@ -83,11 +83,30 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
|
owner := hashedToken(bearerToken(r))
|
||||||
|
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
|
if pending.Owner != owner {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
|
||||||
|
"ok": true,
|
||||||
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
|
Owner: owner,
|
||||||
Size: -1, // the size is only known at finalize time
|
Size: -1, // the size is only known at finalize time
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UsedAt: now,
|
UsedAt: now,
|
||||||
@@ -107,7 +126,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +138,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||||
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
|
}
|
||||||
|
if err == nil && cache == nil {
|
||||||
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
|
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
@@ -127,14 +153,16 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Close() // commitCache needs the store closed
|
|
||||||
|
|
||||||
|
if !cache.Complete {
|
||||||
|
db.Close() // commitCache needs the store closed
|
||||||
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||||
if err := h.commitCache(cache); err != nil {
|
if err := h.commitCache(cache); err != nil {
|
||||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -147,7 +175,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +258,11 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err 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)
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if code == twirpInternal {
|
switch code {
|
||||||
|
case twirpInternal:
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
|
case twirpUnauthenticated:
|
||||||
|
status = http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
list.WriteString(`</BlockList>`)
|
list.WriteString(`</BlockList>`)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
|
||||||
|
"a client that lost the first answer retries the list it already sent")
|
||||||
|
|
||||||
|
var reordered bytes.Buffer
|
||||||
|
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range []string{order[1], order[0], order[2]} {
|
||||||
|
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
reordered.WriteString(`</BlockList>`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
|
||||||
|
"the blocks are already assembled, so a different order would silently disagree with them")
|
||||||
|
|
||||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
|
||||||
|
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
got := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
|
assert.Equal(t, tt.code, got["code"])
|
||||||
|
assert.NotEmpty(t, got["msg"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
const otherJob = "another-jobs-token"
|
||||||
|
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
|
||||||
|
reserve := func(client *http.Client) map[string]any {
|
||||||
|
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
|
||||||
|
}
|
||||||
|
first := reserve(testClient)
|
||||||
|
require.Equal(t, true, first["ok"])
|
||||||
|
retry := reserve(testClient)
|
||||||
|
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
|
||||||
|
entryOf := func(reserved map[string]any) string {
|
||||||
|
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return parsed.Path
|
||||||
|
}
|
||||||
|
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
|
||||||
|
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
|
||||||
|
"another job's reservation would be finalized against the first upload")
|
||||||
|
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
|
||||||
|
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"another job's upload is not this job's to finalize")
|
||||||
|
|
||||||
|
base := handler.ExternalURL() + apiPath
|
||||||
|
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var madeByV1 struct {
|
||||||
|
CacheID uint64 `json:"cacheId"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
|
||||||
|
resp.Body.Close()
|
||||||
|
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||||
|
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||||
|
resp, err = testClient.Do(upload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"a reservation this job did not make through v2 is not its to commit")
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, handler, "deps", "v1", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
|
||||||
|
})
|
||||||
|
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
|
||||||
|
assert.Equal(t, finalized["entry_id"], again["entry_id"])
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ type Cache struct {
|
|||||||
Key string `json:"key" boltholdIndex:"Key"`
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
Version string `json:"version" boltholdIndex:"Version"`
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
|
||||||
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -23,15 +25,33 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
|
|||||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||||
// registered with, and the 404 the router would have written otherwise.
|
// registered with, and the 404 the router would have written otherwise.
|
||||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
cred, ok := h.lookupCredential(bearerToken(r))
|
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cred, ok := h.lookupCredential(bearerToken(r))
|
||||||
|
if !ok {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cred.Results == "" {
|
||||||
|
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
|
||||||
|
return
|
||||||
|
}
|
||||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||||
|
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
|
||||||
|
err = errors.New("not an absolute http or https URL the job could use")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.mustProxy(r, cred, target) {
|
||||||
|
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||||
|
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||||
|
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||||
|
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||||
@@ -44,9 +64,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
// directly and never through here.
|
// directly and never through here.
|
||||||
r.Out.Host = target.Host
|
r.Out.Host = target.Host
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cred.InsecureTLS {
|
if cred.InsecureTLS {
|
||||||
@@ -55,5 +75,27 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
|
||||||
|
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
|
||||||
|
if cred.InsecureTLS && target.Scheme == "https" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
|
||||||
|
scheme = base.Scheme
|
||||||
|
}
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme != target.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuery(target, request string) string {
|
||||||
|
if target == "" || request == "" {
|
||||||
|
return target + request
|
||||||
|
}
|
||||||
|
return target + "&" + request
|
||||||
|
}
|
||||||
|
|
||||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json/v2"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,8 +16,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
|
||||||
// still point at Gitea, and nothing else is proxied.
|
|
||||||
func TestFrontResultsService(t *testing.T) {
|
func TestFrontResultsService(t *testing.T) {
|
||||||
var gotHost, gotPath, gotProto string
|
var gotHost, gotPath, gotProto string
|
||||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||||
|
defer selfSigned.Close()
|
||||||
|
|
||||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
const token = "forward-token"
|
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||||
|
|
||||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
noRedirect := func(token string) *http.Client {
|
||||||
post := func(path string) int {
|
return &http.Client{
|
||||||
|
Transport: &bearerTransport{token: token},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post := func(path string) (int, map[string]string) {
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := client.Do(req)
|
resp, err := noRedirect(token).Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return resp.StatusCode
|
body := map[string]string{}
|
||||||
|
_ = json.UnmarshalRead(resp.Body, &body)
|
||||||
|
return resp.StatusCode, body
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
status, body := post(rpc)
|
||||||
"an unregistered token is forwarded nowhere")
|
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
|
||||||
|
assert.Equal(t, twirpUnauthenticated, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], "bearer")
|
||||||
|
assert.NotEmpty(t, body["msg"])
|
||||||
|
|
||||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
status, _ = post(rpc)
|
||||||
|
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
|
||||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
assert.Equal(t, rpc, gotPath)
|
||||||
|
|
||||||
gotPath = ""
|
gotPath = ""
|
||||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
|
status, _ = post("/api/v1/repos/owner/repo")
|
||||||
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
forwardedProto string
|
||||||
|
reqQuery string
|
||||||
|
want int
|
||||||
|
location string
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||||
|
{
|
||||||
|
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||||
|
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a trusted https instance is reached by the job itself",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the job does not share this server's disregard for the certificate",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||||
|
{
|
||||||
|
name: "a runner too old to send its public URL leaves the terminator to say so",
|
||||||
|
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a scheme the job could not use is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an address with no host is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.forwardedProto != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||||
|
}
|
||||||
|
resp, err := noRedirect(tt.name).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.want, resp.StatusCode)
|
||||||
|
switch tt.want {
|
||||||
|
case http.StatusTemporaryRedirect:
|
||||||
|
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
body := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||||
|
assert.Equal(t, twirpInternal, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
|||||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
// 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.
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
|
||||||
|
if recorded, err := os.ReadFile(list); err == nil {
|
||||||
|
// A retry repeats the list it already sent; another one would reorder what is now staged.
|
||||||
|
if !bytes.Equal(recorded, wanted) {
|
||||||
|
return fmt.Errorf("cache %d was already assembled from a different block list", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
for i, blockID := range blockIDs {
|
for i, blockID := range blockIDs {
|
||||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return os.WriteFile(list, wanted, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -220,14 +221,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
downloads(router, artifactPath)
|
downloads(router, artifactPath)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: net.JoinHostPort(addr, port),
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
// run server
|
// run server
|
||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-102
@@ -325,33 +325,6 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, false, nil
|
return r, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
|
||||||
fetchOptions.Force = true
|
|
||||||
pullOptions.Force = true
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
auth := &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: token,
|
|
||||||
}
|
|
||||||
fetchOptions.Auth = auth
|
|
||||||
pullOptions.Auth = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchOptions, pullOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// staleRefreshErr reports why a failed refresh must abort: the resolve and
|
|
||||||
// checkout that follow are local and succeed on a cancelled context, which
|
|
||||||
// would hand back the cached revision as if it were fresh.
|
|
||||||
func staleRefreshErr(ctx context.Context, err error) error {
|
|
||||||
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -373,25 +346,34 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
isOfflineMode := input.OfflineMode
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
// fetch latest changes
|
fetchOptions := git.FetchOptions{
|
||||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||||
|
Force: true,
|
||||||
if input.InsecureSkipTLS { // For Gitea
|
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||||
fetchOptions.InsecureSkipTLS = true
|
}
|
||||||
pullOptions.InsecureSkipTLS = true
|
if input.Token != "" {
|
||||||
|
fetchOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
shallow := isShallow(r)
|
if isShallow(r) {
|
||||||
if shallow {
|
|
||||||
fetchOptions.Depth = 1
|
fetchOptions.Depth = 1
|
||||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||||
|
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||||
|
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||||
|
_, present := refRevision(r, input.Ref)
|
||||||
|
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||||
|
|
||||||
|
if refresh {
|
||||||
err = r.FetchContext(ctx, &fetchOptions)
|
err = r.FetchContext(ctx, &fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return err
|
return err
|
||||||
@@ -411,23 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point we need to know if it's a tag or a branch
|
rev, _ = refRevision(r, input.Ref)
|
||||||
// And the easiest way to do it is duck typing
|
|
||||||
//
|
|
||||||
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
|
||||||
// it was a sha
|
|
||||||
refType := "tag"
|
|
||||||
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
|
||||||
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
|
||||||
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
||||||
refType = "sha"
|
|
||||||
rev = plumbing.Revision(input.Ref)
|
|
||||||
} else {
|
|
||||||
refType = "branch"
|
|
||||||
rev = plumbing.Revision(rName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
@@ -439,47 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
|
||||||
// using a branch or tag ref, not a sha
|
|
||||||
//
|
|
||||||
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
|
||||||
// we try and pull down any changes
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
|
||||||
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
|
||||||
Branch: sourceRef,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reusedMsg := ""
|
reusedMsg := ""
|
||||||
|
if isOfflineMode && reused {
|
||||||
switch {
|
|
||||||
case !isOfflineMode && !shallow:
|
|
||||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
|
||||||
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
|
||||||
}
|
|
||||||
if err := staleRefreshErr(ctx, err); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case isOfflineMode && reused:
|
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
Hash: *hash,
|
Hash: *hash,
|
||||||
Force: true,
|
Force: true,
|
||||||
@@ -488,23 +420,24 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = w.Reset(&git.ResetOptions{
|
|
||||||
Mode: git.HardReset,
|
|
||||||
Commit: *hash,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Checked out %s", input.Ref)
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
|
||||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
if input.Depth > 0 {
|
if input.Depth > 0 {
|
||||||
|
if plumbing.IsHash(input.Ref) {
|
||||||
|
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
||||||
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for _, refName := range []plumbing.ReferenceName{
|
for _, refName := range []plumbing.ReferenceName{
|
||||||
plumbing.NewBranchReferenceName(input.Ref),
|
plumbing.NewBranchReferenceName(input.Ref),
|
||||||
plumbing.NewTagReferenceName(input.Ref),
|
plumbing.NewTagReferenceName(input.Ref),
|
||||||
@@ -520,8 +453,9 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
@@ -530,14 +464,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removePartialClone(dir string) error {
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("remove partial clone %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
||||||
|
r, err := git.PlainInit(input.Dir, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.FetchContext(ctx, &git.FetchOptions{
|
||||||
|
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
||||||
|
Depth: input.Depth,
|
||||||
|
Tags: git.NoTags,
|
||||||
|
Auth: opts.Auth,
|
||||||
|
Progress: opts.Progress,
|
||||||
|
InsecureSkipTLS: opts.InsecureSkipTLS,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
||||||
|
func pinnedRefSpec(sha string) config.RefSpec {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||||
|
func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||||
|
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||||
|
return plumbing.Revision(ref), err == nil
|
||||||
|
}
|
||||||
|
if _, err := r.Tag(ref); err == nil {
|
||||||
|
return plumbing.Revision(path.Join("refs", "tags", ref)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
return plumbing.Revision(remoteRef), true
|
||||||
|
}
|
||||||
|
rev := plumbing.Revision(ref)
|
||||||
|
_, err := r.ResolveRevision(rev)
|
||||||
|
return rev, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
func isShallow(r *git.Repository) bool {
|
func isShallow(r *git.Repository) bool {
|
||||||
shallows, err := r.Storer.Shallow()
|
shallows, err := r.Storer.Shallow()
|
||||||
return err == nil && len(shallows) > 0
|
return err == nil && len(shallows) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
|
||||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
tagRef := plumbing.NewTagReferenceName(ref)
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
if _, err := r.Reference(tagRef, false); err == nil {
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
@@ -548,5 +533,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
|||||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
}
|
}
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// The broad refspec carries only advertised tips, never a pinned commit.
|
||||||
|
return pinnedRefSpec(ref), true
|
||||||
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
+131
-16
@@ -6,7 +6,6 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
|
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||||
|
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -435,7 +438,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
}
|
}
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
@@ -461,14 +464,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||||
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
|
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
assert.NoFileExists(t, shallowMarker(dir))
|
||||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
})
|
||||||
|
|
||||||
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||||
|
remote := unadvertisedRemote(t, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
cloneAt := func(ref string) error {
|
||||||
|
return NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remote, Ref: ref, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||||
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(remote))
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
@@ -491,6 +523,98 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Ref string
|
||||||
|
Depth int
|
||||||
|
}{
|
||||||
|
"shallow branch": {"main", 1},
|
||||||
|
"full clone branch": {"main", 0},
|
||||||
|
"full lightweight tag": {"v1", 0},
|
||||||
|
"full annotated tag": {"v2", 0},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
counter := installCountingTransport(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||||
|
})
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, string(status))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
|
pinned := gitRevParse(t, workDir, "HEAD")
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
||||||
|
|
||||||
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingTransport struct {
|
||||||
|
transport.Transport
|
||||||
|
sessions atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||||
|
c.sessions.Add(1)
|
||||||
|
return c.Transport.NewUploadPackSession(ep, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCountingTransport(t *testing.T) *countingTransport {
|
||||||
|
t.Helper()
|
||||||
|
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||||
|
gogitclient.InstallProtocol("file", counter)
|
||||||
|
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||||
|
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
|
||||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
@@ -632,12 +756,3 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|||||||
t.Fatal("fetch ignored context cancellation")
|
t.Fatal("fetch ignored context cancellation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStaleRefreshErr(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, errors.New("remote hung up")))
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
require.ErrorIs(t, staleRefreshErr(ctx, errors.New("remote hung up")), context.Canceled)
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, gogit.NoErrAlreadyUpToDate))
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -75,6 +77,24 @@ const (
|
|||||||
// fragment, missingContainerError composes it into the message every operation shares.
|
// fragment, missingContainerError composes it into the message every operation shares.
|
||||||
var ErrContainerNotFound = errors.New("does not exist")
|
var ErrContainerNotFound = errors.New("does not exist")
|
||||||
|
|
||||||
|
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
||||||
|
type DockerProxy struct {
|
||||||
|
Socket string
|
||||||
|
close func(context.Context) error
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
mounts atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) SetMounts(mounts map[string]string) {
|
||||||
|
p.mounts.Store(mounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||||
|
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
|
||||||
|
return p.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
// Info is a snapshot of a container, as of one inspect.
|
// Info is a snapshot of a container, as of one inspect.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -84,13 +104,14 @@ type Info struct {
|
|||||||
// HealthOutput is the last healthcheck probe's output.
|
// HealthOutput is the last healthcheck probe's output.
|
||||||
HealthOutput string
|
HealthOutput string
|
||||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||||
|
Mounts map[string]string // container path to its source on the daemon
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container for managing docker run containers
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||||
Inspect(ctx context.Context) (*Info, error)
|
Inspect(ctx context.Context) (*Info, error)
|
||||||
DumpLogs(ctx context.Context) error
|
DumpLogs(ctx context.Context) error
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -172,3 +173,47 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsolatedNetwork(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return isolatedNetwork(ctx, cli, addr, jobNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var holderID string
|
||||||
|
for _, summary := range containers.Items {
|
||||||
|
if summary.NetworkSettings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, endpoint := range summary.NetworkSettings.Networks {
|
||||||
|
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
|
||||||
|
holderID = endpoint.NetworkID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if holderID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || holder.Network.Driver != "bridge" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if jobNetwork != "" {
|
||||||
|
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || job.Network.ID == holderID {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return holder.Network.Name, nil
|
||||||
|
}
|
||||||
@@ -6,16 +6,50 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestIsolatedNetwork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
|
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
|
||||||
|
"compose": {NetworkID: "c0ffee", IPAddress: cache},
|
||||||
|
"lan": {NetworkID: "beef", IPAddress: lan},
|
||||||
|
}},
|
||||||
|
}}}, nil)
|
||||||
|
for ref, inspected := range map[string]network.Network{
|
||||||
|
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
|
||||||
|
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
|
||||||
|
"c0f": {ID: "c0ffee"},
|
||||||
|
"jobs": {ID: "f00d"},
|
||||||
|
} {
|
||||||
|
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
|
||||||
|
}
|
||||||
|
check := func(addr netip.Addr, jobNetwork, want string) {
|
||||||
|
got, err := isolatedNetwork(ctx, client, addr, jobNetwork)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, jobNetwork)
|
||||||
|
}
|
||||||
|
check(cache, "", "compose")
|
||||||
|
check(cache, "jobs", "compose")
|
||||||
|
check(cache, "c0f", "")
|
||||||
|
check(cache, "host", "")
|
||||||
|
check(lan, "", "")
|
||||||
|
check(netip.MustParseAddr("10.0.0.5"), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,498 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/image"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
|
"github.com/moby/moby/api/types/volume"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
|
||||||
|
func shortTempDir(t testing.TB) string {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
|
return t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
|
||||||
|
t.Helper()
|
||||||
|
host := cli.DaemonHost()
|
||||||
|
if !strings.HasPrefix(host, "unix://") {
|
||||||
|
t.Skipf("skipping: daemon at %s is not a unix socket", host)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(host, "unix://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerProxy(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||||
|
}
|
||||||
|
bodies := make(chan []byte, 8)
|
||||||
|
createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
|
||||||
|
streamsDone := make(chan struct{}, 2)
|
||||||
|
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
|
||||||
|
listener, err := net.Listen("unix", daemonSocket)
|
||||||
|
require.NoError(t, err)
|
||||||
|
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/create"):
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if r.URL.RawQuery == "wait" {
|
||||||
|
close(createStarted)
|
||||||
|
<-releaseCreate
|
||||||
|
}
|
||||||
|
bodies <- body
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
|
||||||
|
if r.URL.Path == "/exec/error/start" {
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("ordinary"))
|
||||||
|
case rawStreamPath.MatchString(r.URL.Path) || r.URL.Path == "/session":
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
conn, buffered, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if r.Header.Get("Upgrade") != "" {
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nready\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nready\n"))
|
||||||
|
}
|
||||||
|
input, _ := io.ReadAll(buffered)
|
||||||
|
_, _ = conn.Write(append([]byte("final:"), input...))
|
||||||
|
streamsDone <- struct{}{}
|
||||||
|
default:
|
||||||
|
w.Header().Set("Api-Version", "1.47")
|
||||||
|
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
go func() { _ = daemon.Serve(listener) }()
|
||||||
|
t.Cleanup(func() { _ = daemon.Close() })
|
||||||
|
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
|
proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"})
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
||||||
|
}}}
|
||||||
|
t.Cleanup(client.CloseIdleConnections)
|
||||||
|
dialProxy := func(t *testing.T) *net.UnixConn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: proxy.Socket, Net: "unix"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
startStream := func(t *testing.T, path, upgrade string) (*net.UnixConn, *bufio.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dialProxy(t)
|
||||||
|
_, err := fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: docker\r\n%sContent-Length: 2\r\n\r\n{}input", path, upgrade)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
})
|
||||||
|
prefix, err := reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "ready\n", prefix)
|
||||||
|
return conn, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("labels creates", func(t *testing.T) {
|
||||||
|
for _, testCase := range []struct{ path, body, want string }{
|
||||||
|
{
|
||||||
|
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
||||||
|
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
|
||||||
|
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
|
||||||
|
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/networks/create", `{"Name":"n"}`,
|
||||||
|
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "null",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
||||||
|
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("passes other requests through", func(t *testing.T) {
|
||||||
|
resp, err := client.Get("http://docker/v1.47/_ping")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
|
||||||
|
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
|
||||||
|
for _, stream := range []struct{ path, upgrade string }{
|
||||||
|
{path: "/v1.47/exec/abc/start"},
|
||||||
|
{path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
|
||||||
|
} {
|
||||||
|
conn, reader := startStream(t, stream.path, stream.upgrade)
|
||||||
|
require.NoError(t, conn.CloseWrite())
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "final:input", string(output))
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("detached and error responses retain keepalive label injection", func(t *testing.T) {
|
||||||
|
conn := dialProxy(t)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
for endpoint, status := range map[string]int{"detached": http.StatusOK, "error": http.StatusBadRequest} {
|
||||||
|
_, err := fmt.Fprintf(conn, "POST /exec/%s/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}", endpoint)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, status, resp.StatusCode)
|
||||||
|
assert.Equal(t, "ordinary", string(body))
|
||||||
|
_, err = io.WriteString(conn, "POST /volumes/create HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err = http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close joins streams and admitted create", func(t *testing.T) {
|
||||||
|
t.Cleanup(func() { close(releaseCreate) })
|
||||||
|
_, raw := startStream(t, "/exec/live/start", "")
|
||||||
|
_, upgraded := startStream(t, "/session", "Connection: Upgrade\r\nUpgrade: tcp\r\n")
|
||||||
|
creator := dialProxy(t)
|
||||||
|
_, err := io.WriteString(creator, "POST /volumes/create?wait HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}")
|
||||||
|
require.NoError(t, err)
|
||||||
|
<-createStarted
|
||||||
|
require.NoError(t, creator.CloseWrite())
|
||||||
|
closed := make(chan error, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() { closed <- proxy.Close(ctx) }()
|
||||||
|
for _, reader := range []*bufio.Reader{raw, upgraded} {
|
||||||
|
_, err := reader.ReadByte()
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-closed:
|
||||||
|
t.Fatalf("Close returned before admitted create settled: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
releaseCreate <- struct{}{}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
require.NoError(t, <-closed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||||
|
containerFailure := errors.New("container removal failed")
|
||||||
|
listFailure := errors.New("listing failed")
|
||||||
|
volumeFailure := errors.New("volume removal failed")
|
||||||
|
ctx := context.Background()
|
||||||
|
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
||||||
|
cli := &mockDockerClient{}
|
||||||
|
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||||
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
||||||
|
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
|
||||||
|
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
|
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
|
||||||
|
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||||
|
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
|
||||||
|
cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once()
|
||||||
|
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
||||||
|
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
|
||||||
|
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
||||||
|
|
||||||
|
err := removeLabelled(ctx, cli, "job-1")
|
||||||
|
require.ErrorIs(t, err, containerFailure)
|
||||||
|
require.ErrorIs(t, err, listFailure)
|
||||||
|
require.ErrorIs(t, err, volumeFailure)
|
||||||
|
require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument)
|
||||||
|
require.ErrorContains(t, err, "failed to remove container c1")
|
||||||
|
require.ErrorContains(t, err, "failed to remove volume app_data")
|
||||||
|
cli.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerProxyWithDaemon(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
|
direct, err := GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer direct.Close()
|
||||||
|
dir := shortTempDir(t)
|
||||||
|
seen, err := daemonSeesDir(ctx, direct, dir, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||||
|
|
||||||
|
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
|
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer viaProxy.Close()
|
||||||
|
|
||||||
|
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||||
|
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
|
||||||
|
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
||||||
|
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
||||||
|
|
||||||
|
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
|
||||||
|
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
|
||||||
|
|
||||||
|
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = attached.Conn.Write([]byte("hello\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
echoed, err := attached.Reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello\r\n", echoed)
|
||||||
|
attached.Close()
|
||||||
|
|
||||||
|
require.NoError(t, proxy.Close(ctx))
|
||||||
|
require.NoError(t, RemoveDockerJobResources(ctx, job))
|
||||||
|
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkDockerProxy(b *testing.B) {
|
||||||
|
ctx := context.Background()
|
||||||
|
direct, err := GetDockerClient(ctx)
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer direct.Close()
|
||||||
|
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
b.Skipf("docker daemon unreachable: %v", err)
|
||||||
|
}
|
||||||
|
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer func() { _ = proxy.Close(ctx) }()
|
||||||
|
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer viaProxy.Close()
|
||||||
|
|
||||||
|
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
|
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
|
||||||
|
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
|
require.NoError(b, err)
|
||||||
|
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
payload := make([]byte, 64<<20)
|
||||||
|
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
|
||||||
|
_, _ = writer.Write(payload)
|
||||||
|
require.NoError(b, writer.Close())
|
||||||
|
|
||||||
|
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
|
||||||
|
b.Run("ping/"+name, func(b *testing.B) {
|
||||||
|
for b.Loop() {
|
||||||
|
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
b.Run("copy64MiB/"+name, func(b *testing.B) {
|
||||||
|
b.SetBytes(int64(archive.Len()))
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeClient struct {
|
||||||
|
mobyclient.APIClient
|
||||||
|
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
|
||||||
|
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
return c.create(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
return c.remove(ctx, id, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
markers := make(map[string]bool)
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
private bool
|
||||||
|
removeErr error
|
||||||
|
}{
|
||||||
|
{name: "cleanup after cancellation"},
|
||||||
|
{name: "private filesystem with stale marker", private: true},
|
||||||
|
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
daemonDir := dir
|
||||||
|
if testCase.private {
|
||||||
|
daemonDir = t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
|
||||||
|
}
|
||||||
|
removed := false
|
||||||
|
cli := &probeClient{
|
||||||
|
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
require.Len(t, opts.HostConfig.Mounts, 1)
|
||||||
|
marker := opts.HostConfig.Mounts[0].Source
|
||||||
|
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
|
||||||
|
assert.False(t, markers[marker])
|
||||||
|
markers[marker] = true
|
||||||
|
info, err := os.Stat(marker)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.Mode().IsRegular())
|
||||||
|
cancel()
|
||||||
|
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||||
|
},
|
||||||
|
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removed = true
|
||||||
|
assert.Equal(t, "probe", id)
|
||||||
|
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
assert.True(t, bounded)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seen, err := daemonSeesDir(ctx, cli, dir, dir)
|
||||||
|
require.ErrorIs(t, err, testCase.removeErr)
|
||||||
|
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
|
||||||
|
assert.Equal(t, !testCase.private, removed)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("docker socket ownership is unavailable")
|
||||||
|
}
|
||||||
|
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||||
|
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := uint16(info.Mode().Perm())
|
||||||
|
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
owner, group, other := mode>>6, mode>>3&7, mode&7
|
||||||
|
acl := binary.LittleEndian.AppendUint32(nil, 2)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
tag, perm uint16
|
||||||
|
id uint32
|
||||||
|
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
|
||||||
|
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
|
||||||
|
}
|
||||||
|
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||||
|
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||||
|
}
|
||||||
+54
-27
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -138,12 +139,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
|||||||
).IfNot(common.Dryrun)
|
).IfNot(common.Dryrun)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
cr.find(),
|
cr.find(),
|
||||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
// If this fails, then folders have wrong permissions on non root container
|
// If this fails, then folders have wrong permissions on non root container
|
||||||
if cr.UID != 0 || cr.GID != 0 {
|
if cr.UID != 0 || cr.GID != 0 {
|
||||||
@@ -252,6 +253,31 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
|||||||
ID: inspect.ID,
|
ID: inspect.ID,
|
||||||
Health: HealthNone,
|
Health: HealthNone,
|
||||||
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
||||||
|
Mounts: make(map[string]string, len(inspect.Mounts)),
|
||||||
|
}
|
||||||
|
for _, mountPoint := range inspect.Mounts {
|
||||||
|
info.Mounts[mountPoint.Destination] = mountPoint.Source
|
||||||
|
}
|
||||||
|
if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths
|
||||||
|
for target := range hostConfig.Tmpfs {
|
||||||
|
info.Mounts[path.Clean(target)] = ""
|
||||||
|
}
|
||||||
|
for _, spec := range hostConfig.Mounts {
|
||||||
|
subpath := ""
|
||||||
|
if spec.VolumeOptions != nil {
|
||||||
|
subpath = spec.VolumeOptions.Subpath
|
||||||
|
} else if spec.ImageOptions != nil {
|
||||||
|
subpath = spec.ImageOptions.Subpath
|
||||||
|
}
|
||||||
|
if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" {
|
||||||
|
info.Mounts[target] = path.Join(info.Mounts[target], subpath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace
|
||||||
|
if _, mounted := info.Mounts[target]; !mounted {
|
||||||
|
info.Mounts[target] = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if state := inspect.State; state != nil {
|
if state := inspect.State; state != nil {
|
||||||
@@ -484,14 +510,11 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
switch {
|
if cerrdefs.IsConflict(err) {
|
||||||
case cerrdefs.IsConflict(err):
|
err = cr.waitForRemoval(ctx, idOrName)
|
||||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
}
|
||||||
// references and the network endpoint only once it finishes
|
if err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
cr.waitForRemoval(ctx, idOrName)
|
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
|
||||||
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", idOrName)
|
logger.Debugf("Removed container: %v", idOrName)
|
||||||
@@ -500,7 +523,7 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
|
||||||
// per container, against the one minute the post-job executor allows for the whole
|
// 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
|
// cleanup, so a job with several services can spend most of that budget here
|
||||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
@@ -510,18 +533,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
|||||||
Condition: container.WaitConditionRemoved,
|
Condition: container.WaitConditionRemoved,
|
||||||
})
|
})
|
||||||
select {
|
select {
|
||||||
case <-waitResult.Result:
|
case result := <-waitResult.Result:
|
||||||
case <-waitResult.Error:
|
if result.Error != nil {
|
||||||
case <-ctx.Done():
|
return errors.New(result.Error.Message)
|
||||||
// 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:
|
|
||||||
}
|
}
|
||||||
}()
|
return nil
|
||||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
case err := <-waitResult.Error:
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +601,14 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
// workflow aliases join the runner's own, deduped so a re-create cannot grow the input
|
||||||
|
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if !slices.Contains(cr.input.NetworkAliases, alias) {
|
||||||
|
cr.input.NetworkAliases = append(cr.input.NetworkAliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged {
|
||||||
@@ -932,7 +958,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||||
@@ -976,6 +1002,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
|
SkipGitDir: skipGitDir,
|
||||||
Handler: &filecollector.TarCollector{
|
Handler: &filecollector.TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
UID: cr.UID,
|
UID: cr.UID,
|
||||||
@@ -1264,15 +1291,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
|||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []*glob.Pattern
|
||||||
host []glob.Glob
|
host []*glob.Pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ret := validVolumeMatcher{
|
ret := validVolumeMatcher{
|
||||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
named: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
host: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
for _, v := range validVolumes {
|
||||||
|
|||||||
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.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) {
|
||||||
|
removeFailure := errors.New("driver failed to remove root filesystem")
|
||||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
wantWait bool
|
wantWait bool
|
||||||
wantFailure bool
|
waitErr error
|
||||||
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||||
|
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
|
||||||
|
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
|
||||||
{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"), wantFailure: true},
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := test.NewNullLogger()
|
||||||
@@ -366,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
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 {
|
if tc.wantWait {
|
||||||
removed := make(chan container.WaitResponse, 1)
|
removed := make(chan container.WaitResponse, 1)
|
||||||
|
waitErrors := make(chan error, 1)
|
||||||
|
if tc.waitErr == nil {
|
||||||
removed <- container.WaitResponse{}
|
removed <- container.WaitResponse{}
|
||||||
|
} else {
|
||||||
|
waitErrors <- tc.waitErr
|
||||||
|
}
|
||||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
||||||
}
|
}
|
||||||
cr := &containerReference{id: "abc", cli: client}
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
require.NoError(t, cr.remove()(ctx))
|
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
||||||
// a failure keeps the id, so a later Remove() can retry it
|
if tc.wantErr != nil {
|
||||||
if tc.wantFailure {
|
|
||||||
assert.Equal(t, "abc", cr.id)
|
assert.Equal(t, "abc", cr.id)
|
||||||
assert.Len(t, hook.AllEntries(), 1)
|
|
||||||
} else {
|
} else {
|
||||||
assert.Empty(t, cr.id)
|
assert.Empty(t, cr.id)
|
||||||
assert.Empty(t, hook.AllEntries())
|
|
||||||
}
|
}
|
||||||
|
assert.Empty(t, hook.AllEntries())
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -471,7 +478,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||||
}
|
}
|
||||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
|
||||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||||
check("GetContainerArchive", err)
|
check("GetContainerArchive", err)
|
||||||
@@ -801,6 +808,31 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
assert.Equal(t, "abc123", info.ID)
|
assert.Equal(t, "abc123", info.ID)
|
||||||
assert.Equal(t, HealthNone, info.Health)
|
assert.Equal(t, HealthNone, info.Health)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("reports the mount sources by container path", func(t *testing.T) {
|
||||||
|
info := containerInfoFromInspect(container.InspectResponse{
|
||||||
|
Mounts: []container.MountPoint{
|
||||||
|
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
|
||||||
|
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
|
||||||
|
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
|
||||||
|
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
|
||||||
|
},
|
||||||
|
HostConfig: &container.HostConfig{
|
||||||
|
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
||||||
|
"/workspace/owner/repo/tmp": "",
|
||||||
|
"/var/run/docker.sock": "/var/run/docker.sock",
|
||||||
|
"/cache": "/var/lib/docker/volumes/cache/_data/project",
|
||||||
|
"/etc/resolv.conf": "/custom/resolv.conf",
|
||||||
|
"/etc/hosts": "",
|
||||||
|
"/etc/hostname": "",
|
||||||
|
}, info.Mounts)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
@@ -833,6 +865,17 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|||||||
assert.Equal(t, 1, warnings("--network host", ""))
|
assert.Equal(t, 1, warnings("--network host", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
|
||||||
|
}
|
||||||
|
|
||||||
// A dead daemon must fail the job, not panic through logrus and not silently
|
// A dead daemon must fail the job, not panic through logrus and not silently
|
||||||
// drop the requested platform.
|
// drop the requested platform.
|
||||||
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func IsolatedNetwork(context.Context, netip.Addr, string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
@@ -29,6 +34,18 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveDockerJobResources(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
|
return nil, errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
|
||||||
// NewDockerBuildExecutor function to create a run executor for the container
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -77,3 +94,11 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,40 +8,67 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
return func(ctx context.Context) error {
|
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
for _, volumeName := range volumeNames {
|
||||||
|
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
Labels: runnerLabels(runnerUUID),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vol := range list.Items {
|
var errs []error
|
||||||
if vol.Name == volumeName {
|
for _, item := range volumes.Items {
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
created, err := time.Parse(time.RFC3339, item.CreatedAt)
|
||||||
|
// an unreadable or recent timestamp may belong to a job that is still starting up
|
||||||
|
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
|
||||||
// Volume not found - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||||
logger.Debugf("docker volume rm %s", volume)
|
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||||
|
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
srcPrefix := filepath.Dir(srcPath)
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
@@ -113,6 +113,7 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
|||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
|
SkipGitDir: skipGitDir,
|
||||||
Handler: &filecollector.CopyCollector{
|
Handler: &filecollector.CopyCollector{
|
||||||
DstDir: destPath,
|
DstDir: destPath,
|
||||||
},
|
},
|
||||||
@@ -629,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
|||||||
func goArchToActionArch(arch string) string {
|
func goArchToActionArch(arch string) string {
|
||||||
archMapper := map[string]string{
|
archMapper := map[string]string{
|
||||||
"x86_64": "X64",
|
"x86_64": "X64",
|
||||||
|
"amd64": "X64",
|
||||||
"386": "X86",
|
"386": "X86",
|
||||||
|
"arm": "ARM",
|
||||||
"aarch64": "ARM64",
|
"aarch64": "ARM64",
|
||||||
|
"arm64": "ARM64",
|
||||||
}
|
}
|
||||||
if arch, ok := archMapper[arch]; ok {
|
if arch, ok := archMapper[arch]; ok {
|
||||||
return arch
|
return arch
|
||||||
@@ -640,7 +644,9 @@ func goArchToActionArch(arch string) string {
|
|||||||
|
|
||||||
func goOsToActionOs(os string) string {
|
func goOsToActionOs(os string) string {
|
||||||
osMapper := map[string]string{
|
osMapper := map[string]string{
|
||||||
|
"linux": "Linux",
|
||||||
"darwin": "macOS",
|
"darwin": "macOS",
|
||||||
|
"windows": "Windows",
|
||||||
}
|
}
|
||||||
if os, ok := osMapper[os]; ok {
|
if os, ok := osMapper[os]; ok {
|
||||||
return os
|
return os
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestActionPlatformNames(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
|
||||||
|
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
|
|||||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyDirSkipGitDir(t *testing.T) {
|
||||||
|
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
src := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "dest")
|
||||||
|
e := &HostEnvironment{StdOut: os.Stdout}
|
||||||
|
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
||||||
|
|
||||||
|
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||||
|
if skipGitDir {
|
||||||
|
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||||
|
} else {
|
||||||
|
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetContainerArchive(t *testing.T) {
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
||||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||||
case multiLineEnv != -1:
|
case multiLineEnv != -1:
|
||||||
multiLineEnvContent := ""
|
var multiLineEnvContent []string
|
||||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||||
delimiterFound := false
|
delimiterFound := false
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
@@ -59,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
delimiterFound = true
|
delimiterFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if multiLineEnvContent != "" {
|
multiLineEnvContent = append(multiLineEnvContent, content)
|
||||||
multiLineEnvContent += "\n"
|
|
||||||
}
|
|
||||||
multiLineEnvContent += content
|
|
||||||
}
|
}
|
||||||
if err := s.Err(); err != nil {
|
if err := s.Err(); err != nil {
|
||||||
return fmt.Errorf("reading env file: %w", err)
|
return fmt.Errorf("reading env file: %w", err)
|
||||||
@@ -70,7 +67,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
if !delimiterFound {
|
if !delimiterFound {
|
||||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||||
}
|
}
|
||||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
|||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
||||||
|
env = map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "\nline2", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ type FileCollector struct {
|
|||||||
Ignorer gitignore.Matcher
|
Ignorer gitignore.Matcher
|
||||||
SrcPath string
|
SrcPath string
|
||||||
SrcPrefix string
|
SrcPrefix string
|
||||||
|
SkipGitDir bool
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if fc.SkipGitDir && split[len(split)-1] == ".git" {
|
||||||
|
if fi.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var entry *index.Entry
|
var entry *index.Entry
|
||||||
if i != nil {
|
if i != nil {
|
||||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
||||||
|
|||||||
+80
-40
@@ -32,7 +32,7 @@ type actionStep interface {
|
|||||||
step
|
step
|
||||||
|
|
||||||
getActionModel() *model.Action
|
getActionModel() *model.Action
|
||||||
getCompositeRunContext(context.Context) *RunContext
|
getCompositeRunContext(context.Context) (*RunContext, error)
|
||||||
getCompositeSteps() *compositeSteps
|
getCompositeSteps() *compositeSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +147,14 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
|
|
||||||
if !rc.Config.NoActionPatch {
|
if !rc.Config.NoActionPatch {
|
||||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
||||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
|
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||||
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
actionLocation := path.Join(actionDir, actionPath)
|
actionLocation := path.Join(actionDir, actionPath)
|
||||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||||
@@ -185,7 +187,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
@@ -232,6 +234,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
|
||||||
|
func nodeActionCommand(script string) []string {
|
||||||
|
return []string{"node", "--preserve-symlinks-main", script}
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
||||||
// files in .gitignore are not copied in a Docker container
|
// files in .gitignore are not copied in a Docker container
|
||||||
// this causes issues with actions that ignore other important resources
|
// this causes issues with actions that ignore other important resources
|
||||||
@@ -347,19 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
|
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
||||||
|
}
|
||||||
|
cmd, err := shellquote.Split(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cmd) == 0 {
|
ee, err := evalDockerEnv(ctx, step, action)
|
||||||
cmd = action.Runs.Args
|
if err != nil {
|
||||||
evalDockerArgs(ctx, step, action, &cmd)
|
return err
|
||||||
|
}
|
||||||
|
if action.Runs.Args != nil {
|
||||||
|
// a fresh slice, the manifest is evaluated again for every stage
|
||||||
|
cmd = make([]string, len(action.Runs.Args))
|
||||||
|
for i, v := range action.Runs.Args {
|
||||||
|
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate runs.args: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
stepContainer.Pull(forcePull),
|
stepContainer.Pull(forcePull),
|
||||||
@@ -381,10 +404,16 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
case stepStagePost:
|
case stepStagePost:
|
||||||
entrypoint = runs.PostEntrypoint
|
entrypoint = runs.PostEntrypoint
|
||||||
default:
|
default:
|
||||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
entrypoint = runs.Entrypoint
|
||||||
|
if entrypoint == "" {
|
||||||
|
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
|
||||||
return fields, nil
|
return fields, nil
|
||||||
}
|
}
|
||||||
entrypoint = runs.Entrypoint
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entrypoint == "" {
|
if entrypoint == "" {
|
||||||
@@ -393,36 +422,41 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
return shellquote.Split(entrypoint)
|
return shellquote.Split(entrypoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
||||||
|
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
|
var err error
|
||||||
inputs := make(map[string]string)
|
inputs := make(map[string]string)
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
// Set Defaults
|
// Set Defaults
|
||||||
for k, input := range action.Inputs {
|
for k, input := range action.Inputs {
|
||||||
inputs[k] = eval.Interpolate(ctx, input.Default)
|
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if stepModel.With != nil {
|
|
||||||
for k, v := range stepModel.With {
|
for k, v := range stepModel.With {
|
||||||
inputs[k] = eval.Interpolate(ctx, v)
|
if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergeIntoMap(step, step.getEnv(), inputs)
|
mergeIntoMap(step, step.getEnv(), inputs)
|
||||||
|
|
||||||
stepEE := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
|
||||||
for i, v := range *cmd {
|
mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
|
||||||
(*cmd)[i] = stepEE.Interpolate(ctx, v)
|
*step.getEnv() = env
|
||||||
}
|
|
||||||
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
|
|
||||||
|
|
||||||
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
|
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return ee, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
@@ -432,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts, err := rc.GetBindsAndMounts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
}
|
}
|
||||||
return ContainerNewContainer(&container.NewContainerInput{
|
return ContainerNewContainer(&container.NewContainerInput{
|
||||||
@@ -456,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||||
@@ -469,15 +506,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
|
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
for inputID, input := range action.Inputs {
|
for inputID, input := range action.Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
envKey = "INPUT_" + envKey
|
envKey = "INPUT_" + envKey
|
||||||
if _, ok := (*env)[envKey]; !ok {
|
if _, ok := (*env)[envKey]; !ok {
|
||||||
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||||
@@ -577,16 +618,19 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||||
|
|
||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
|
if !x.IsComposite() {
|
||||||
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case x.IsNode():
|
case x.IsNode():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
@@ -594,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
return execDockerActionStage(ctx, step, stepStagePre)
|
return execDockerActionStage(ctx, step, stepStagePre)
|
||||||
|
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if step.getCompositeSteps() == nil {
|
if step.getCompositeSteps() == nil {
|
||||||
step.getCompositeRunContext(ctx)
|
if _, err := step.getCompositeRunContext(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
||||||
@@ -610,9 +653,6 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
return errors.New("missing steps in composite action")
|
return errors.New("missing steps in composite action")
|
||||||
|
|
||||||
case x == model.ActionRunsUsingGo:
|
case x == model.ActionRunsUsingGo:
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -687,7 +727,7 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
|
||||||
env := make(map[string]string)
|
env := make(map[string]string)
|
||||||
stepEnv := *step.getEnv()
|
stepEnv := *step.getEnv()
|
||||||
for k, v := range stepEnv {
|
for k, v := range stepEnv {
|
||||||
@@ -36,23 +37,44 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
|
|
||||||
// lookup if key is defined in the step but the already
|
// lookup if key is defined in the step but the already
|
||||||
// evaluated value from the environment
|
// evaluated value from the environment
|
||||||
_, defined := step.getStepModel().With[inputID]
|
defined := false
|
||||||
|
for key := range step.getStepModel().With {
|
||||||
|
if strings.EqualFold(key, inputID) {
|
||||||
|
defined = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if value, ok := stepEnv[envKey]; defined && ok {
|
if value, ok := stepEnv[envKey]; defined && ok {
|
||||||
env[envKey] = value
|
env[envKey] = value
|
||||||
} else {
|
} else {
|
||||||
// defaults could contain expressions
|
// defaults could contain expressions
|
||||||
env[envKey] = ee.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gh := step.getGithubContext(ctx)
|
gh := step.getGithubContext(ctx)
|
||||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
||||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
||||||
|
|
||||||
return env
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
|
func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
|
||||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
rc.setActionEnv(env)
|
||||||
|
for key := range rc.Env {
|
||||||
|
if strings.HasPrefix(key, "INPUT_") {
|
||||||
|
delete(rc.Env, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
|
||||||
|
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// run with the global config but without secrets
|
// run with the global config but without secrets
|
||||||
configCopy := *parent.Config
|
configCopy := *parent.Config
|
||||||
@@ -62,12 +84,13 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
compositerc := &RunContext{
|
compositerc := &RunContext{
|
||||||
Name: parent.Name,
|
Name: parent.Name,
|
||||||
JobName: parent.JobName,
|
JobName: parent.JobName,
|
||||||
|
Matrix: parent.Matrix,
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: parent.Run.JobID,
|
JobID: parent.Run.JobID,
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Name: parent.Run.Workflow.Name,
|
Name: parent.Run.Workflow.Name,
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
parent.Run.JobID: {},
|
parent.Run.JobID: {Strategy: parent.Run.Job().Strategy},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,11 +103,12 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
ExtraPath: parent.ExtraPath,
|
ExtraPath: parent.ExtraPath,
|
||||||
Parent: parent,
|
Parent: parent,
|
||||||
EventJSON: parent.EventJSON,
|
EventJSON: parent.EventJSON,
|
||||||
|
platformImage: parent.platformImage,
|
||||||
}
|
}
|
||||||
compositerc.setActionEnv(env)
|
compositerc.setCompositeActionEnv(env)
|
||||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
return compositerc
|
return compositerc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||||
@@ -105,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
compositeRC := step.getCompositeRunContext(ctx)
|
compositeRC, err := step.getCompositeRunContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
steps := step.getCompositeSteps()
|
steps := step.getCompositeSteps()
|
||||||
|
|
||||||
@@ -115,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
|
|
||||||
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
||||||
|
|
||||||
err := steps.main(ctx)
|
err = steps.main(ctx)
|
||||||
|
|
||||||
// Map outputs from composite RunContext to job RunContext
|
// Map outputs from composite RunContext to job RunContext
|
||||||
eval := compositeRC.NewExpressionEvaluator(ctx)
|
eval := compositeRC.NewExpressionEvaluator(ctx)
|
||||||
for outputName, output := range action.Outputs {
|
for outputName, output := range action.Outputs {
|
||||||
rc.setOutput(ctx, map[string]string{
|
value, outputErr := eval.Interpolate(ctx, output.Value)
|
||||||
"name": outputName,
|
if outputErr != nil {
|
||||||
}, eval.Interpolate(ctx, output.Value))
|
err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||||
@@ -131,7 +161,7 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
// repeated composite actions grow rc.Masks exponentially.
|
// repeated composite actions grow rc.Masks exponentially.
|
||||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||||
rc.ExtraPath = compositeRC.ExtraPath
|
rc.ExtraPath = compositeRC.ExtraPath
|
||||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
// Propagate GlobalEnv only, so composite inputs and step-local values do not escape.
|
||||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||||
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||||
mergeIntoMap = mergeIntoMapCaseInsensitive
|
mergeIntoMap = mergeIntoMapCaseInsensitive
|
||||||
@@ -194,6 +224,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
steps = append(steps, common.JobError)
|
steps = append(steps, common.JobError)
|
||||||
|
preSteps = append(preSteps, common.JobError)
|
||||||
return &compositeSteps{
|
return &compositeSteps{
|
||||||
pre: func(ctx context.Context) error {
|
pre: func(ctx context.Context) error {
|
||||||
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
||||||
|
|||||||
@@ -6,9 +6,58 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCompositeActionParity(t *testing.T) {
|
||||||
|
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
strategy := &model.Strategy{MaxParallelString: "3"}
|
||||||
|
parent := &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Matrix: map[string]any{"os": "linux"},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
platformImage: "-self-hosted",
|
||||||
|
}
|
||||||
|
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||||
|
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
||||||
|
RunContext: parent,
|
||||||
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
|
||||||
|
env: map[string]string{"INPUT_SHARED": "outer"},
|
||||||
|
}, "/action")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
||||||
|
assert.True(t, composite.IsHostEnv())
|
||||||
|
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||||
|
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "linux|3|outer", interpolated)
|
||||||
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
|
|
||||||
|
nestedEnv := composite.GetEnv()
|
||||||
|
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
|
||||||
|
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates pre failures", func(t *testing.T) {
|
||||||
|
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, rc.compositeExecutor(&model.Action{Runs: model.ActionRuns{Using: "composite", Steps: []model.Step{{ID: "nested", Uses: "org/action@v1"}}}}).pre(t.Context()), assert.AnError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendUniqueMasks(t *testing.T) {
|
func TestAppendUniqueMasks(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+82
-23
@@ -8,6 +8,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -223,7 +227,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
|
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
||||||
for k, v := range tt.expectedEnv {
|
for k, v := range tt.expectedEnv {
|
||||||
@@ -234,7 +238,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
tt.step.getRunContext().JobContainer = cm
|
tt.step.getRunContext().JobContainer = cm
|
||||||
|
|
||||||
@@ -246,6 +250,32 @@ func TestActionRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlink creation requires privileges on Windows")
|
||||||
|
}
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
actionDir := t.TempDir()
|
||||||
|
entrypoint := filepath.Join(actionDir, "index.mjs")
|
||||||
|
require.NoError(t, os.WriteFile(entrypoint, []byte(`
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const self = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] !== self) {
|
||||||
|
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
|
||||||
|
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
|
||||||
|
|
||||||
|
args := nodeActionCommand(symlinkedEntrypoint)
|
||||||
|
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
|
||||||
|
require.NoError(t, err, "%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
|
|
||||||
@@ -286,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||||
step.On("getEnv").Return(&env)
|
step.On("getEnv").Return(&env)
|
||||||
|
|
||||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
_, _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||||
assert.Empty(t, captured.Username)
|
assert.Empty(t, captured.Username)
|
||||||
@@ -307,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|||||||
copyEntered := make(chan struct{})
|
copyEntered := make(chan struct{})
|
||||||
|
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
|
||||||
close(copyEntered)
|
close(copyEntered)
|
||||||
<-releaseCopy
|
<-releaseCopy
|
||||||
return nil
|
return nil
|
||||||
@@ -492,8 +522,6 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
|
||||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
|
||||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||||
orig := ContainerNewContainer
|
orig := ContainerNewContainer
|
||||||
defer func() { ContainerNewContainer = orig }()
|
defer func() { ContainerNewContainer = orig }()
|
||||||
@@ -501,25 +529,62 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
stage stepStage
|
stage stepStage
|
||||||
|
with map[string]string
|
||||||
|
runs model.ActionRuns
|
||||||
|
env map[string]string
|
||||||
|
wantCmd []string
|
||||||
wantEntrypoint []string
|
wantEntrypoint []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "main stage prefers the entrypoint input",
|
name: "main stage prefers manifest values",
|
||||||
stage: stepStageMain,
|
stage: stepStageMain,
|
||||||
wantEntrypoint: []string{"input.sh"},
|
with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
Entrypoint: "main.sh",
|
||||||
|
Args: []string{"manifest"},
|
||||||
|
Env: map[string]string{"ACTION_ONLY": "manifest"},
|
||||||
|
},
|
||||||
|
wantCmd: []string{"manifest"},
|
||||||
|
wantEntrypoint: []string{"main.sh"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pre stage uses runs.pre-entrypoint",
|
name: "main stage uses caller fallbacks",
|
||||||
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"},
|
||||||
|
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
|
||||||
|
wantCmd: []string{"caller", "--flag"},
|
||||||
|
wantEntrypoint: []string{"input.sh", "--verbose"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit empty manifest args suppress caller args",
|
||||||
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller"},
|
||||||
|
runs: model.ActionRuns{Args: []string{}},
|
||||||
|
wantCmd: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pre stage keeps step environment",
|
||||||
stage: stepStagePre,
|
stage: stepStagePre,
|
||||||
|
with: map[string]string{"entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
PreEntrypoint: "pre.sh --verbose",
|
||||||
|
Args: []string{"hello"},
|
||||||
|
Env: map[string]string{"SHARED": "manifest"},
|
||||||
|
},
|
||||||
|
env: map[string]string{"SHARED": "step"},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "post stage uses runs.post-entrypoint",
|
name: "post stage uses manifest entrypoint",
|
||||||
stage: stepStagePost,
|
stage: stepStagePost,
|
||||||
|
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"post.sh"},
|
wantEntrypoint: []string{"post.sh"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
var input *container.NewContainerInput
|
var input *container.NewContainerInput
|
||||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
@@ -528,22 +593,14 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
step := &stepActionRemote{
|
step := &stepActionRemote{
|
||||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
action: &model.Action{Runs: model.ActionRuns{
|
action: &model.Action{Runs: tc.runs},
|
||||||
Using: "docker",
|
env: mergeMaps(tc.env),
|
||||||
Image: "docker://node:14",
|
|
||||||
PreEntrypoint: "pre.sh --verbose",
|
|
||||||
Entrypoint: "main.sh",
|
|
||||||
PostEntrypoint: "post.sh",
|
|
||||||
Args: []string{"hello"},
|
|
||||||
Env: map[string]string{"MY_VAR": "world"},
|
|
||||||
}},
|
|
||||||
env: map[string]string{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
@@ -554,9 +611,11 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||||
require.NotNil(t, input)
|
require.NotNil(t, input)
|
||||||
|
assert.Equal(t, tc.wantCmd, input.Cmd)
|
||||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
for key, value := range mergeMaps(tc.runs.Env, tc.env) {
|
||||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
assert.Contains(t, input.Env, key+"="+value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
|||||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||||
|
setJobResult(context.Background(), rc, rc, true)
|
||||||
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||||
|
|||||||
+42
-11
@@ -18,11 +18,17 @@ var commandPatternGA *regexp.Regexp
|
|||||||
var commandPatternADO *regexp.Regexp
|
var commandPatternADO *regexp.Regexp
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
|
commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
|
||||||
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$")
|
// excluding ']' ends the command info at the first bracket, as GitHub does
|
||||||
|
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
||||||
|
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
|
||||||
|
return command, kvPairs, arg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
|
||||||
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ",")
|
kvPairs = parseKeyValuePairs(m[3], ",")
|
||||||
@@ -32,19 +38,21 @@ func tryParseRawActionCommand(line string) (command string, kvPairs map[string]s
|
|||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ";")
|
kvPairs = parseKeyValuePairs(m[3], ";")
|
||||||
arg = m[4]
|
arg = m[4]
|
||||||
|
legacy = true
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
return command, kvPairs, arg, ok
|
return command, kvPairs, arg, legacy, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
resumeCommand := ""
|
resumeCommand := ""
|
||||||
return func(line string) bool {
|
return func(line string) bool {
|
||||||
command, kvPairs, arg, ok := tryParseRawActionCommand(line)
|
command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
|
||||||
if !ok {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
command = strings.ToLower(command)
|
||||||
|
|
||||||
if resumeCommand != "" {
|
if resumeCommand != "" {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
@@ -54,19 +62,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
// Resumed here rather than from the switch, because the end token is arbitrary
|
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||||
// and a token naming a real command would otherwise never resume.
|
// and a token naming a real command would otherwise never resume.
|
||||||
if command == resumeCommand {
|
if strings.EqualFold(command, resumeCommand) {
|
||||||
resumeCommand = ""
|
resumeCommand = ""
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if legacy {
|
||||||
|
arg = UnescapeLegacyCommand(arg)
|
||||||
|
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
|
||||||
|
} else {
|
||||||
arg = UnescapeCommandData(arg)
|
arg = UnescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
|
||||||
|
}
|
||||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
rc.setEnv(ctx, kvPairs, arg)
|
rc.setEnv(ctx, kvPairs, arg, true)
|
||||||
case "set-output":
|
case "set-output":
|
||||||
rc.setOutput(ctx, kvPairs, arg)
|
rc.setOutput(ctx, kvPairs, arg)
|
||||||
case "add-path":
|
case "add-path":
|
||||||
@@ -139,8 +152,16 @@ func (rc *RunContext) takeUnsecureCommandError() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) {
|
||||||
name := kvPairs["name"]
|
name := kvPairs["name"]
|
||||||
|
if strings.EqualFold(name, "NODE_OPTIONS") {
|
||||||
|
message := "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command."
|
||||||
|
if fromCommand {
|
||||||
|
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
|
||||||
|
}
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
if rc.Env == nil {
|
if rc.Env == nil {
|
||||||
rc.Env = make(map[string]string)
|
rc.Env = make(map[string]string)
|
||||||
@@ -159,6 +180,10 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
|||||||
mergeIntoMap(rc.GlobalEnv, newenv)
|
mergeIntoMap(rc.GlobalEnv, newenv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
|
rc.setEnv(ctx, kvPairs, arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
stepID := rc.CurrentStep
|
stepID := rc.CurrentStep
|
||||||
@@ -189,7 +214,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
rtn := make(map[string]string)
|
rtn := make(map[string]string)
|
||||||
kvPairList := strings.SplitSeq(kvPairs, separator)
|
kvPairList := strings.SplitSeq(kvPairs, separator)
|
||||||
for kvPair := range kvPairList {
|
for kvPair := range kvPairList {
|
||||||
kv := strings.Split(kvPair, "=")
|
kv := strings.SplitN(kvPair, "=", 2)
|
||||||
if len(kv) == 2 {
|
if len(kv) == 2 {
|
||||||
rtn[kv[0]] = kv[1]
|
rtn[kv[0]] = kv[1]
|
||||||
}
|
}
|
||||||
@@ -202,6 +227,7 @@ var (
|
|||||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||||
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
||||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||||
|
legacyCommandUnescaper = strings.NewReplacer("%3B", ";", "%0D", "\r", "%0A", "\n", "%5D", "]", "%25", "%")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||||
@@ -218,9 +244,14 @@ func unescapeCommandProperty(arg string) string {
|
|||||||
return commandPropertyUnescaper.Replace(arg)
|
return commandPropertyUnescaper.Replace(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
// UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
|
||||||
|
func UnescapeLegacyCommand(arg string) string {
|
||||||
|
return legacyCommandUnescaper.Replace(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
|
||||||
for k, v := range kvPairs {
|
for k, v := range kvPairs {
|
||||||
kvPairs[k] = unescapeCommandProperty(v)
|
kvPairs[k] = unescape(v)
|
||||||
}
|
}
|
||||||
return kvPairs
|
return kvPairs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ func unsecureRC() *RunContext {
|
|||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
logger, hook := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
|
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
|
||||||
|
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
||||||
|
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||||
|
a.NotContains(rc.Env, "node_options")
|
||||||
|
entries := hook.AllEntries()
|
||||||
|
require.Len(t, entries, 3)
|
||||||
|
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
||||||
|
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
@@ -85,6 +94,16 @@ func TestSetOutput(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
||||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
||||||
|
handler("::set-output name=symbol::std::vector")
|
||||||
|
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
|
||||||
|
handler("::set-output name=a=b::value\n")
|
||||||
|
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
|
||||||
|
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
|
||||||
|
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
|
||||||
|
handler("##[set-output name=bracket]value]tail")
|
||||||
|
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
|
||||||
|
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
|
||||||
|
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
@@ -110,7 +129,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::stop-commands::my-end-token\n")
|
handler("::stop-commands::MY-END-TOKEN\n")
|
||||||
handler("::set-env name=x::abcd\n")
|
handler("::set-env name=x::abcd\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::my-end-token::\n")
|
handler("::my-end-token::\n")
|
||||||
@@ -163,10 +182,10 @@ func TestAddmask(t *testing.T) {
|
|||||||
|
|
||||||
rc := new(RunContext)
|
rc := new(RunContext)
|
||||||
handler := rc.commandHandler(loggerCtx)
|
handler := rc.commandHandler(loggerCtx)
|
||||||
handler("::add-mask::my-secret-value\n")
|
handler("::ADD-MASK::my::secret")
|
||||||
|
|
||||||
a.Equal("***", hook.LastEntry().Message)
|
a.Equal("***", hook.LastEntry().Message)
|
||||||
a.NotEqual("*my-secret-value", hook.LastEntry().Message)
|
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on https://stackoverflow.com/a/10476304
|
// based on https://stackoverflow.com/a/10476304
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
|
|||||||
return args.Get(0).(func(context.Context) error)
|
return args.Get(0).(func(context.Context) error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
args := cm.Called(destPath, srcPath, useGitIgnore)
|
args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
|
||||||
return args.Get(0).(func(context.Context) error)
|
return args.Get(0).(func(context.Context) error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerProxyMountedJob(t *testing.T) {
|
||||||
|
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
|
||||||
|
if mode == "" {
|
||||||
|
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
|
||||||
|
}
|
||||||
|
require.Contains(t, []string{"proxy", "direct"}, mode)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
dockerClient, err := container.GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
|
||||||
|
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
|
||||||
|
|
||||||
|
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
|
||||||
|
runner, err := New(&Config{
|
||||||
|
Workdir: fixtureDir,
|
||||||
|
ActionCacheDir: t.TempDir(),
|
||||||
|
EventName: "push",
|
||||||
|
PlatformPicker: mapPlatformPicker(platforms),
|
||||||
|
ContainerNamePrefix: resourceName,
|
||||||
|
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||||
|
ContainerMaxLifetime: 2 * time.Minute,
|
||||||
|
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
||||||
|
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
||||||
|
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
if mode == "direct" {
|
||||||
|
plan, err = planner.PlanJob("proxy")
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
jobName := runContext.jobContainerName()
|
||||||
|
jobNetwork, _ := runContext.networkNameForGitea()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
|
defer cleanCancel()
|
||||||
|
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobNetwork} {
|
||||||
|
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
|
||||||
|
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hook := &test.Hook{}
|
||||||
|
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
||||||
|
var messages []string
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||||
|
}
|
||||||
|
require.Contains(t, messages, "docker proxy post verified")
|
||||||
|
if mode == "proxy" {
|
||||||
|
require.Contains(t, messages, "docker binds verified")
|
||||||
|
}
|
||||||
|
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
||||||
|
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-54
@@ -13,6 +13,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -26,6 +27,29 @@ import (
|
|||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// setStrategyContext leaves `max-parallel` unset when the job declares none, as GitHub does.
|
||||||
|
func setStrategyContext(strategy map[string]any, jobStrategy *model.Strategy) {
|
||||||
|
strategy["fail-fast"] = jobStrategy.GetFailFast()
|
||||||
|
if limit, declared, err := jobStrategy.ParseMaxParallel(); declared && err == nil {
|
||||||
|
strategy["max-parallel"] = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeDeferred decodes a whole-value `${{ }}` parked in a Raw* field, evaluating a clone since the node is shared across matrix combinations.
|
||||||
|
func decodeDeferred[T any](ctx context.Context, eval *expressionEvaluator, name string, raw yaml.Node, out *T) error {
|
||||||
|
if raw.Kind != yaml.ScalarNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
node := model.CloneYamlNode(raw)
|
||||||
|
if err := eval.EvaluateYamlNode(ctx, &node); err != nil {
|
||||||
|
return fmt.Errorf("unable to evaluate %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := node.Decode(out); err != nil {
|
||||||
|
return fmt.Errorf("unable to decode %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewExpressionEvaluator creates a new evaluator
|
// NewExpressionEvaluator creates a new evaluator
|
||||||
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
|
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
|
||||||
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
||||||
@@ -40,8 +64,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
if rc.Run != nil {
|
if rc.Run != nil {
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
if job != nil && job.Strategy != nil {
|
if job != nil && job.Strategy != nil {
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
setStrategyContext(strategy, job.Strategy)
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
jobs := rc.Run.Workflow.Jobs
|
||||||
@@ -71,7 +94,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
}
|
}
|
||||||
|
|
||||||
ghc := rc.getGithubContext(ctx)
|
ghc := rc.getGithubContext(ctx)
|
||||||
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc)
|
inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
|
||||||
|
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: ghc,
|
Github: ghc,
|
||||||
@@ -81,7 +104,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the step outputs on the job
|
// but required to interpolate/evaluate the step outputs on the job
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
@@ -117,8 +140,7 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
|||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
strategy := make(map[string]any)
|
strategy := make(map[string]any)
|
||||||
if job.Strategy != nil {
|
if job.Strategy != nil {
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
setStrategyContext(strategy, job.Strategy)
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
jobs := rc.Run.Workflow.Jobs
|
||||||
@@ -137,14 +159,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
|||||||
Env: *step.getEnv(),
|
Env: *step.getEnv(),
|
||||||
Job: rc.getJobContext(),
|
Job: rc.getJobContext(),
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: using,
|
||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the inputs in actions/composite
|
// but required to interpolate/evaluate the inputs in actions/composite
|
||||||
Inputs: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)),
|
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
@@ -246,17 +268,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
|
|||||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
|
||||||
out, err := ee.interpolate(ctx, in)
|
return ee.shared(ctx).Interpolate(in)
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
|
||||||
return ee.shared(ctx).Interpolate(in)
|
func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
|
||||||
|
out, err := ee.Interpolate(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
@@ -277,10 +300,10 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
|||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
setupWorkflowInputs(ctx, &inputs, rc)
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
maps.Copy(inputs, stepInputs)
|
maps.Copy(inputs, stepInputs)
|
||||||
|
|
||||||
if ghc.EventName == "workflow_dispatch" {
|
if ghc.EventName == "workflow_dispatch" {
|
||||||
@@ -324,54 +347,53 @@ func coerceInputValue(value any, inputType string) any {
|
|||||||
return value == "true"
|
return value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
||||||
if rc.caller != nil {
|
func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
||||||
|
if rc.caller == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
|
callerEval := rc.caller.runContext.ExprEval
|
||||||
|
callerWith := callerJob.With
|
||||||
|
if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||||
|
|
||||||
|
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
|
||||||
for name, input := range config.Inputs {
|
for name, input := range config.Inputs {
|
||||||
value := rc.caller.runContext.Run.Job().With[name]
|
value, eval, label := callerWith[name], callerEval, "input"
|
||||||
if value != nil {
|
if value == nil {
|
||||||
|
value, eval, label = input.Default, calleeEval(), "the default of input"
|
||||||
|
}
|
||||||
if str, ok := value.(string); ok {
|
if str, ok := value.(string); ok {
|
||||||
// evaluate using the calling RunContext (outside)
|
var err error
|
||||||
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
|
if value, err = eval.Interpolate(ctx, str); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if value == nil && config != nil && config.Inputs != nil {
|
secrets := callerJob.Secrets()
|
||||||
value = input.Default
|
if secrets == nil && callerJob.InheritSecrets() {
|
||||||
if rc.ExprEval != nil {
|
|
||||||
if str, ok := value.(string); ok {
|
|
||||||
// evaluate using the called RunContext (inside)
|
|
||||||
value = rc.ExprEval.Interpolate(ctx, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
|
||||||
if rc.caller != nil {
|
|
||||||
job := rc.caller.runContext.Run.Job()
|
|
||||||
secrets := job.Secrets()
|
|
||||||
|
|
||||||
if secrets == nil && job.InheritSecrets() {
|
|
||||||
secrets = rc.caller.runContext.Config.Secrets
|
secrets = rc.caller.runContext.Config.Secrets
|
||||||
}
|
}
|
||||||
|
rc.workflowCallSecrets = make(map[string]string, len(secrets))
|
||||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
|
||||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
|
||||||
// in place is a data race.
|
|
||||||
interpolated := make(map[string]string, len(secrets))
|
|
||||||
for k, v := range secrets {
|
for k, v := range secrets {
|
||||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
var err error
|
||||||
|
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return interpolated
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWorkflowSecrets(rc *RunContext) map[string]string {
|
||||||
|
if rc.caller != nil {
|
||||||
|
return rc.workflowCallSecrets
|
||||||
|
}
|
||||||
return rc.Config.Secrets
|
return rc.Config.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
|||||||
out any
|
out any
|
||||||
errMesg string
|
errMesg string
|
||||||
}{
|
}{
|
||||||
{" 1 ", 1, ""},
|
{" 1 ", 1.0, ""},
|
||||||
// {"1 + 3", "4", ""},
|
// {"1 + 3", "4", ""},
|
||||||
// {"(1 + 3) * -2", "-8", ""},
|
// {"(1 + 3) * -2", "-8", ""},
|
||||||
{"'my text'", "my text", ""},
|
{"'my text'", "my text", ""},
|
||||||
@@ -266,19 +266,20 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ null }}", ""},
|
{"${{ null }}", ""},
|
||||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
|
||||||
{"${{ 1) && (2 }}", ""},
|
|
||||||
{"run ${{ 1) && (2 }} now", ""},
|
|
||||||
{"${{ 1", "${{ 1"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run("interpolate", func(t *testing.T) {
|
t.Run("interpolate", func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
out, err := ee.Interpolate(context.Background(), table.in)
|
||||||
out := ee.Interpolate(context.Background(), table.in)
|
require.NoError(t, err, table.in)
|
||||||
assertObject.Equal(table.out, out, table.in)
|
assert.Equal(t, table.out, out, table.in)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||||
|
_, err := ee.Interpolate(context.Background(), in)
|
||||||
|
assert.Error(t, err, in)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
@@ -352,12 +353,38 @@ on:
|
|||||||
}
|
}
|
||||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||||
|
|
||||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
inputs := getEvaluatorInputs(rc, nil, ghc)
|
||||||
assert.Equal(t, table.flag, inputs["flag"])
|
assert.Equal(t, table.flag, inputs["flag"])
|
||||||
assert.Equal(t, "gitea", inputs["name"])
|
assert.Equal(t, "gitea", inputs["name"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("deferred workflow call inputs", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + `
|
||||||
|
jobs:
|
||||||
|
test: {}
|
||||||
|
call:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
with: ${{ fromJSON(matrix.args) }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
workflow.GetJob("test")
|
||||||
|
job := workflow.GetJob("call")
|
||||||
|
rawWith := model.CloneYamlNode(job.RawWith)
|
||||||
|
for _, value := range []string{"true", "false"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"runner"}`})
|
||||||
|
require.NoError(t, err)
|
||||||
|
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{"flag": value == "true", "name": "runner"}, child.workflowCallInputs)
|
||||||
|
assert.Equal(t, rawWith, job.RawWith)
|
||||||
|
assert.Nil(t, job.With)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobNameMasksSecrets(t *testing.T) {
|
func TestJobNameMasksSecrets(t *testing.T) {
|
||||||
@@ -372,7 +399,8 @@ jobs:
|
|||||||
|
|
||||||
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
||||||
containerName := func(jobID string) string {
|
containerName := func(jobID string) string {
|
||||||
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, rc.Name, "s3cr3t")
|
assert.NotContains(t, rc.Name, "s3cr3t")
|
||||||
return rc.jobContainerName()
|
return rc.jobContainerName()
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-30
@@ -18,6 +18,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
if rc.Run == nil {
|
if rc.Run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
|
||||||
// evaluate environment variables since they can contain
|
reportStepError(ctx, rc, err)
|
||||||
// GitHub's special environment variables.
|
return err
|
||||||
for k, v := range rc.GetEnv() {
|
|
||||||
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -236,6 +235,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
// Ahead of the teardown below, while the job environment is still up.
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||||
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
|
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
||||||
|
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
} else if err := setJobOutputs(ctx, rc); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
@@ -251,24 +259,23 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
setJobResult(ctx, info, rc, jobError == nil)
|
setJobResult(ctx, info, rc, jobError == nil)
|
||||||
setJobOutputs(ctx, rc)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
return common.Executor(func(ctx context.Context) error {
|
||||||
|
if err := info.startContainer()(ctx); err != nil {
|
||||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
return err
|
||||||
Finally(func(ctx context.Context) error {
|
}
|
||||||
|
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
|
||||||
// Record an interrupt (backstop for interrupts that land outside the main
|
// Record an interrupt (backstop for interrupts that land outside the main
|
||||||
// step loop) so the post steps observe the cancelled/failed job status.
|
// step loop) so the post steps observe the cancelled/failed job status.
|
||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
postCtx, cancel := postStepsContext(ctx)
|
postCtx, cancel := postStepsContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return postExecutor(postCtx)
|
return postExecutor(postCtx)
|
||||||
}).
|
})(ctx)
|
||||||
Finally(info.interpolateOutputs()).
|
}).Finally(info.closeContainer())
|
||||||
Finally(info.closeContainer()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||||
@@ -365,7 +372,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
// concurrent succeeding one.
|
// concurrent succeeding one.
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
var continueOnError bool
|
var continueOnError bool
|
||||||
if !success {
|
if !success && !rc.jobCancelled {
|
||||||
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
||||||
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
||||||
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
||||||
@@ -378,7 +385,11 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
if len(info.matrix()) > 0 && job.Result != "" {
|
if len(info.matrix()) > 0 && job.Result != "" {
|
||||||
result = job.Result
|
result = job.Result
|
||||||
}
|
}
|
||||||
if !success {
|
// cancelled is sticky, so a sibling combination finishing last cannot mask it
|
||||||
|
switch {
|
||||||
|
case rc.jobCancelled:
|
||||||
|
result = "cancelled"
|
||||||
|
case !success && result != "cancelled":
|
||||||
result = "failure"
|
result = "failure"
|
||||||
job.SetContinueOnError(continueOnError)
|
job.SetContinueOnError(continueOnError)
|
||||||
}
|
}
|
||||||
@@ -392,23 +403,60 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobResultMessage := "succeeded"
|
jobResultMessage := "failed"
|
||||||
if jobResult != "success" {
|
switch jobResult {
|
||||||
jobResultMessage = "failed"
|
case "success":
|
||||||
|
jobResultMessage = "succeeded"
|
||||||
|
case "cancelled":
|
||||||
|
jobResultMessage = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
|
||||||
if rc.caller != nil {
|
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||||
// map outputs for reusable workflows
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
callerOutputs := make(map[string]string)
|
var workflowEnv map[string]string
|
||||||
|
if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if workflowEnv != nil {
|
||||||
|
rc.Env = mergeMaps(workflowEnv, rc.GetEnv())
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
for k, v := range rc.GetEnv() {
|
||||||
|
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults := rc.Run.Job().Defaults.Run
|
||||||
|
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
|
||||||
|
}
|
||||||
|
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||||
|
if rc.caller == nil {
|
||||||
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
|
return nil
|
||||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
}
|
||||||
|
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
|
||||||
|
callerOutputs := make(map[string]string, len(outputs))
|
||||||
|
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||||
|
for k, v := range outputs {
|
||||||
|
value := v.Value
|
||||||
|
for range 2 { // two passes, the value resolves through a job output
|
||||||
|
var err error
|
||||||
|
if value, err = ee().Interpolate(ctx, value); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callerOutputs[k] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||||
@@ -416,14 +464,16 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
|||||||
callerJob := rc.caller.runContext.Run.Job()
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
defer lockJob(callerJob)()
|
defer lockJob(callerJob)()
|
||||||
callerJob.Outputs = callerOutputs
|
callerJob.Outputs = callerOutputs
|
||||||
}
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||||
// step-level evaluateStepTimeout in step.go.
|
// step-level evaluateStepTimeout in step.go.
|
||||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||||
if timeout != "" {
|
if err != nil {
|
||||||
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
||||||
|
} else if timeout != "" {
|
||||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
@@ -622,7 +672,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
|||||||
|
|
||||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||||
|
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps []string
|
executedSteps []string
|
||||||
result string
|
result string
|
||||||
hasError bool
|
hasError bool
|
||||||
|
output string
|
||||||
|
startError error
|
||||||
|
cancelOnStart bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "zeroSteps",
|
name: "zeroSteps",
|
||||||
@@ -319,8 +322,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -336,8 +339,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "failure",
|
result: "failure",
|
||||||
@@ -354,8 +357,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -372,8 +375,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -391,8 +394,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -418,13 +421,37 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"step3",
|
"step3",
|
||||||
"post3",
|
"post3",
|
||||||
"post2",
|
"post2",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
hasError: false,
|
hasError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "jobOutputExpressionFailure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{false},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "failure",
|
||||||
|
output: "${{ 'test' != test }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start failure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
executedSteps: []string{"startContainer", "closeContainer"},
|
||||||
|
startError: errors.New("start failed"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled at startup boundary",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{true},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "cancelled",
|
||||||
|
cancelOnStart: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
contains := func(needle string, haystack []string) bool {
|
contains := func(needle string, haystack []string) bool {
|
||||||
@@ -435,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
ctx := common.WithJobErrorContainer(context.Background())
|
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||||
|
defer cancel()
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
@@ -450,15 +478,22 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
if tt.output != "" {
|
||||||
|
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
|
||||||
|
rc.outputTemplate = map[string]string{"bad": tt.output}
|
||||||
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
executorOrder := make([]string, 0)
|
executorOrder := make([]string, 0)
|
||||||
|
|
||||||
jim.On("steps").Return(tt.steps)
|
jim.On("steps").Return(tt.steps)
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 {
|
||||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
jim.On("startContainer").Return(func(_ context.Context) error {
|
||||||
executorOrder = append(executorOrder, "startContainer")
|
executorOrder = append(executorOrder, "startContainer")
|
||||||
return nil
|
if tt.cancelOnStart {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return tt.startError
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,23 +527,31 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
defer sm.AssertExpectations(t)
|
defer sm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 && tt.startError == nil {
|
||||||
jim.On("matrix").Return(map[string]any{})
|
jim.On("matrix").Return(map[string]any{})
|
||||||
|
|
||||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "interpolateOutputs")
|
executorOrder = append(executorOrder, "interpolateOutputs")
|
||||||
|
if tt.output != "" {
|
||||||
|
return rc.interpolateOutputs()(ctx)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if contains("stopContainer", tt.executedSteps) {
|
if contains("stopContainer", tt.executedSteps) {
|
||||||
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "stopContainer")
|
executorOrder = append(executorOrder, "stopContainer")
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
require.True(t, bounded)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
jim.On("result", tt.result)
|
jim.On("result", tt.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tt.steps) > 0 {
|
||||||
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "closeContainer")
|
executorOrder = append(executorOrder, "closeContainer")
|
||||||
return nil
|
return nil
|
||||||
@@ -517,7 +560,15 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
|
|
||||||
executor := newJobExecutor(jim, sfm, rc)
|
executor := newJobExecutor(jim, sfm, rc)
|
||||||
err := executor(ctx)
|
err := executor(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
switch {
|
||||||
|
case tt.startError != nil:
|
||||||
|
require.ErrorIs(t, err, tt.startError)
|
||||||
|
case tt.cancelOnStart:
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||||
|
|
||||||
jim.AssertExpectations(t)
|
jim.AssertExpectations(t)
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
env := maps.Clone(rc.GetEnv())
|
env := map[string]string{}
|
||||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||||
maps.Copy(env, jobContainer.Env)
|
maps.Copy(env, jobContainer.Env)
|
||||||
}
|
}
|
||||||
|
maps.Copy(env, rc.GetEnv())
|
||||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
rc.ApplyExtraPath(ctx, &env)
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
@@ -95,10 +96,11 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||||
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnvFile)
|
||||||
return err
|
if pathErr := rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)); pathErr != nil && err == nil {
|
||||||
|
err = pathErr
|
||||||
}
|
}
|
||||||
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||||
|
|||||||
+68
-42
@@ -168,49 +168,77 @@ 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{
|
var secretValueEncoders = []func(string) string{
|
||||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||||
base64ShiftEncoder(1),
|
base64ShiftEncoder(1),
|
||||||
base64ShiftEncoder(2),
|
base64ShiftEncoder(2),
|
||||||
|
base64InteriorEncoder(0),
|
||||||
|
base64InteriorEncoder(1),
|
||||||
|
base64InteriorEncoder(2),
|
||||||
|
expressionStringEscape,
|
||||||
jsonStringEscape,
|
jsonStringEscape,
|
||||||
jsonStringEscapeNoHTML,
|
jsonStringEscapeNoHTML,
|
||||||
url.QueryEscape,
|
uriDataEscape,
|
||||||
|
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
|
||||||
url.PathEscape,
|
url.PathEscape,
|
||||||
|
xmlDataEscape,
|
||||||
|
trimDoubleQuotes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
// base64ShiftEncoder reproduces the 3-byte alignments of `Basic base64("user:token")`, and
|
||||||
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
// its padded tail only matches a secret that ends the payload.
|
||||||
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 {
|
func base64ShiftEncoder(shift int) func(string) string {
|
||||||
|
return func(v string) string {
|
||||||
|
value := []byte(v)
|
||||||
|
if len(value) > shift {
|
||||||
|
value = value[shift:]
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minInteriorBase64Len = 8 // below this a fragment matches unrelated output
|
||||||
|
|
||||||
|
// base64InteriorEncoder keeps the aligned middle, so a secret with data after it still matches.
|
||||||
|
func base64InteriorEncoder(shift int) func(string) string {
|
||||||
return func(v string) string {
|
return func(v string) string {
|
||||||
buf := make([]byte, shift+len(v))
|
buf := make([]byte, shift+len(v))
|
||||||
copy(buf[shift:], v)
|
copy(buf[shift:], v)
|
||||||
encoded := base64.StdEncoding.EncodeToString(buf)
|
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||||
// Keep only the aligned middle, and only when enough of it is left to be a
|
if len(encoded) < 8+minInteriorBase64Len {
|
||||||
// distinctive pattern rather than a fragment that matches unrelated output.
|
|
||||||
if len(encoded) < 8+minShiftedBase64Len {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return encoded[4 : len(encoded)-4]
|
return encoded[4 : len(encoded)-4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expressionStringEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(v, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uriDataEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(v), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
var xmlDataEscaper = strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
|
||||||
|
func xmlDataEscape(v string) string {
|
||||||
|
return xmlDataEscaper.Replace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDoubleQuotes(v string) string {
|
||||||
|
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
|
||||||
|
return v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
// 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
|
// 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
|
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||||
@@ -245,36 +273,34 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
|
|||||||
return oldnew
|
return oldnew
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendSecretMasker registers v and each of its lines, as GitHub does.
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
ret := oldnew
|
ret := appendMaskedValue(oldnew, v)
|
||||||
|
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
|
||||||
|
ret = appendMaskedValue(ret, strings.TrimSpace(l))
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
for l := range strings.SplitSeq(v, "\n") {
|
// appendMaskedValue registers one value and every shape it takes on its way into a log.
|
||||||
tm := strings.TrimSpace(l)
|
func appendMaskedValue(ret []string, v string) []string {
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||||
if len(tm) > 1 {
|
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
|
||||||
ret = append(ret, tm, "***")
|
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
|
||||||
if strings.ContainsAny(tm, "%\r\n") {
|
|
||||||
ret = append(ret, EscapeCommandData(tm), "***")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
return ret
|
||||||
}
|
}
|
||||||
|
ret = append(ret, v, "***")
|
||||||
|
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||||
|
if strings.ContainsAny(v, "%\r\n") {
|
||||||
|
ret = append(ret, EscapeCommandData(v), "***")
|
||||||
|
}
|
||||||
for _, encode := range secretValueEncoders {
|
for _, encode := range secretValueEncoders {
|
||||||
encoded := encode(trimmed)
|
encoded := encode(v)
|
||||||
// An encoding that leaves the value unchanged is already masked above.
|
// An encoding that leaves the value unchanged is already masked above.
|
||||||
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ret = append(ret, encoded, "***")
|
ret = append(ret, encoded, "***")
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-29
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
|
|||||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
// 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.
|
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||||
secret := `p@ss w"rd/1`
|
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name, secret string
|
||||||
line string
|
encoded []string
|
||||||
}{
|
}{
|
||||||
{"verbatim", "the token is " + secret},
|
{"common encodings", `p@ss w"rd/1`, []string{
|
||||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
`p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
|
||||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
|
||||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
}},
|
||||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
{"XML expression and quotes", `"a'b&c<d>"`, []string{
|
||||||
|
`"a'b&c<d>"`, `"a''b&c<d>"`, `a'b&c<d>`,
|
||||||
|
}},
|
||||||
|
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
assert.NotContains(t, entry.Message, secret)
|
for _, disallowed := range tc.encoded {
|
||||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
assert.NotContains(t, entry.Message, disallowed)
|
||||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,15 +125,21 @@ func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
|||||||
|
|
||||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||||
masks := []string{"s3cr3t value"}
|
masks := []string{"s3cr3t value", "first\rsecond", " s3cr3t "}
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{
|
for _, tc := range []struct {
|
||||||
Context: WithMasks(t.Context(), &masks),
|
line, want string
|
||||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
}{
|
||||||
})
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
|
||||||
|
{"first and second", "*** and ***"},
|
||||||
assert.Equal(t, "encoded: ***", entry.Message)
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||||
|
} {
|
||||||
|
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
|
||||||
|
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||||
@@ -140,7 +147,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||||
// the token to anyone who can decode the log.
|
// the token to anyone who can decode the log.
|
||||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||||
secret := "s3cr3t-token-value"
|
secret := "s3cr3t"
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||||
|
|
||||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||||
@@ -150,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
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)
|
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||||
@@ -190,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
|||||||
// JSON, query and path escaping all leave it unchanged.
|
// JSON, query and path escaping all leave it unchanged.
|
||||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"plaintoken", "***",
|
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
||||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
||||||
// The two shifted alignments, each without its leading and trailing group.
|
|
||||||
"YWludG9r", "***",
|
|
||||||
"bGFpbnRv", "***",
|
|
||||||
}, pairs)
|
}, pairs)
|
||||||
|
|
||||||
// Too short to mask.
|
// Too short to mask.
|
||||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||||
|
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
||||||
|
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -34,9 +35,14 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
expectedMaxParallel: 4,
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "max-parallel-10",
|
name: "max-parallel-10-clamped-to-combinations",
|
||||||
maxParallelString: "10",
|
maxParallelString: "10",
|
||||||
expectedMaxParallel: 10,
|
expectedMaxParallel: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max-parallel-invalid-falls-back-to-default",
|
||||||
|
maxParallelString: "tow",
|
||||||
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +67,29 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, matrixes)
|
assert.NotNil(t, matrixes)
|
||||||
assert.Len(t, matrixes, 5)
|
assert.Len(t, matrixes, 5)
|
||||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("deferred strategy", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
if: false
|
||||||
|
strategy: ${{ fromJSON('{"max-parallel":2,"fail-fast":false,"matrix":{"os":["ubuntu","windows"]}}') }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := workflow.Jobs["test"]
|
||||||
|
rawStrategy := model.CloneYamlNode(job.RawStrategy)
|
||||||
|
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
|
require.NotNil(t, job.Strategy)
|
||||||
|
assert.False(t, job.Strategy.GetFailFast())
|
||||||
|
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
|
||||||
|
matrixes, err := job.GetMatrixes()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []map[string]any{{"os": "ubuntu"}, {"os": "windows"}}, matrixes)
|
||||||
|
assert.Equal(t, rawStrategy, job.RawStrategy)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
||||||
@@ -81,4 +107,15 @@ func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
|||||||
runner := &runnerImpl{config: &Config{}}
|
runner := &runnerImpl{config: &Config{}}
|
||||||
|
|
||||||
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
|
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
|
||||||
|
|
||||||
|
for _, strategy := range []string{
|
||||||
|
`${{ fromJSON('invalid') }}`,
|
||||||
|
`${{ '${{ inputs.unresolved }}' }}`,
|
||||||
|
} {
|
||||||
|
t.Run(strategy, func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n strategy: " + strategy))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, runner.NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+24
-16
@@ -28,8 +28,8 @@ import (
|
|||||||
// of upload-artifact.
|
// of upload-artifact.
|
||||||
//
|
//
|
||||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
// 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
|
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
|
||||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
// client reaches the cache server by its own address rather than through the results origin.
|
||||||
//
|
//
|
||||||
// Either of these landing upstream makes this file deletable:
|
// Either of these landing upstream makes this file deletable:
|
||||||
//
|
//
|
||||||
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's
|
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
|
||||||
// clone lock, which is what keeps another job's checkout from resetting them before the copy.
|
func patchActions(ctx context.Context, scripts []string) func() {
|
||||||
func patchActions(ctx context.Context, scripts []string) {
|
restore := map[string][]byte{}
|
||||||
for _, script := range scripts {
|
for _, script := range scripts {
|
||||||
switch patched, err := patchBundle(script); {
|
original, err := patchBundle(script)
|
||||||
case err != nil:
|
if original != nil {
|
||||||
|
restore[script] = original // also when the write failed part way through
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||||
case patched:
|
}
|
||||||
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
|
}
|
||||||
|
return func() {
|
||||||
|
for script, original := range restore {
|
||||||
|
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
|
||||||
|
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func patchBundle(script string) (bool, error) {
|
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||||
|
func patchBundle(script string) ([]byte, error) {
|
||||||
info, err := os.Stat(script)
|
info, err := os.Stat(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.Size() > maxBundleSize {
|
if info.Size() > maxBundleSize {
|
||||||
return false, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(script)
|
data, err := os.ReadFile(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
patched, ok := patchedBundle(data)
|
patched, ok := patchedBundle(data)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// No atomic write needed: every prepare checks the action out and hard resets it.
|
return data, os.WriteFile(script, patched, info.Mode().Perm())
|
||||||
return true, os.WriteFile(script, patched, info.Mode().Perm())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||||
|
|||||||
Loaded 50 of 91 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user