mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9db9c0fa | ||
|
|
d1da6ffa52 | ||
|
|
880e0a148a | ||
|
|
2db24c7401 |
No files matched your search
@@ -74,7 +74,7 @@ jobs:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
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.
|
||||
- name: pre-pull test images
|
||||
env:
|
||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
|
||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
run: |
|
||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||
for attempt in 1 2 3; do
|
||||
|
||||
@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
@@ -96,8 +96,13 @@ go-check:
|
||||
fi
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: ## check the Go code formatting
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||
fmt-check: fmt
|
||||
@diff=$$(git diff --color=always -- '*.go'); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: deps-tools
|
||||
deps-tools: ## install tool dependencies
|
||||
@@ -144,8 +149,13 @@ tidy: ## run go mod tidy
|
||||
fi
|
||||
|
||||
.PHONY: tidy-check
|
||||
tidy-check: ## check that go.mod and go.sum are tidy
|
||||
$(GO) mod tidy -diff
|
||||
tidy-check: tidy
|
||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make tidy' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@@ -161,7 +171,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)
|
||||
@./scripts/test-dind.sh $(TARGET)
|
||||
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:6dac556d980b7f0e5498d08f08cee0ca67798b4ad6c23964a9214920e67758d0 # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
|
||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
E2E_CONCURRENCY ?= 8
|
||||
|
||||
@@ -201,10 +201,10 @@ A label is written as:
|
||||
| Part | Meaning |
|
||||
| --- | --- |
|
||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||
| `schema` | Either `docker`, `host`, or `macos-vm`. Defaults to `host` when omitted. |
|
||||
| `args` | Used by `docker` for the image, and by `macos-vm` for the source VM. |
|
||||
|
||||
Two schemas are supported:
|
||||
Three schemas are supported:
|
||||
|
||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||
|
||||
@@ -218,15 +218,21 @@ Two schemas are supported:
|
||||
macos:host
|
||||
```
|
||||
|
||||
- **`macos-vm://<source-vm>`** — the job runs inside an ephemeral macOS VM created by `runner-executor-macos`. The VM image must include the runner guest agent. Service containers are not supported with this schema. See `docs/macos-vm-executor.md` for configuration, integration testing, and cleanup details:
|
||||
|
||||
```text
|
||||
macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
```
|
||||
|
||||
So with the labels
|
||||
|
||||
```text
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host,macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
```
|
||||
|
||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, one with `runs-on: macos` is executed directly on the host, and one with `runs-on: macos-latest` is executed in the macOS VM.
|
||||
|
||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host`, `docker`, and `macos-vm` are treated as schemas.
|
||||
|
||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
@@ -266,17 +272,6 @@ 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.
|
||||
|
||||
#### 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
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
@@ -291,7 +286,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:
|
||||
|
||||
- the built-in cache server, whose address is assigned at startup
|
||||
- the cache server
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
@@ -308,44 +303,61 @@ A password in a proxy URL is hidden in job logs. Any step can still read it, bec
|
||||
|
||||
#### Caching (`actions/cache`)
|
||||
|
||||
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.
|
||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
|
||||
**Eviction**
|
||||
|
||||
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.
|
||||
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled.
|
||||
|
||||
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
|
||||
|
||||
**Cache service v2**
|
||||
|
||||
`actions/cache` 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.
|
||||
`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:
|
||||
|
||||
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.
|
||||
```yaml
|
||||
cache:
|
||||
v2: false
|
||||
```
|
||||
|
||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle 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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -233,7 +234,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||
}
|
||||
|
||||
func (h *Handler) baseURL(cred JobCredential) string {
|
||||
@@ -602,12 +603,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||
return
|
||||
}
|
||||
cred, ok := h.lookupCredential(token)
|
||||
if !ok {
|
||||
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||
@@ -616,14 +617,6 @@ 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
|
||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||
@@ -727,12 +720,6 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
||||
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 {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
@@ -783,9 +770,14 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
if exact != nil {
|
||||
return exact, nil
|
||||
}
|
||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||
re, err := regexp.Compile(prefixPattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").RegExp(re).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err != nil {
|
||||
@@ -813,8 +805,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Key").Eq(key).Index("Key").
|
||||
And("Repo").Eq(repo).
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(complete).
|
||||
SortBy(sortBy).Reverse())
|
||||
@@ -828,16 +820,14 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
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)
|
||||
}
|
||||
// write back id to db
|
||||
if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
|
||||
return fmt.Errorf("write back id to db: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||
return fmt.Errorf("insert cache: %w", err)
|
||||
}
|
||||
// write back id to db
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
return fmt.Errorf("write back id to db: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||
@@ -862,9 +852,6 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||
if requireIncomplete && cache.Complete {
|
||||
return fmt.Errorf("cache %d: already complete", id)
|
||||
}
|
||||
if !touchNeeded(cache) {
|
||||
return nil
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
@@ -874,11 +861,9 @@ const (
|
||||
|
||||
defaultSweepInterval = time.Hour
|
||||
|
||||
touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
|
||||
|
||||
// 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
|
||||
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
||||
// for it, and no sweep cuts off a download in progress.
|
||||
inUseGrace = artifactURLTTL
|
||||
|
||||
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
||||
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
||||
@@ -972,7 +957,7 @@ func (h *Handler) evictExpired(db *bolthold.Store) {
|
||||
return
|
||||
}
|
||||
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
||||
window := max(h.policy.Retention+touchStale, inUseGrace)
|
||||
window := max(h.policy.Retention, inUseGrace)
|
||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||
}
|
||||
|
||||
@@ -1073,18 +1058,10 @@ func inUse(cache *Cache) bool {
|
||||
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
|
||||
// go through touchCache, which opens its own store and would block on the exclusive lock
|
||||
// for as long as the caller holds one.
|
||||
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
||||
if !touchNeeded(cache) {
|
||||
return
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
h.logger.Warnf("touch cache: %v", err)
|
||||
|
||||
@@ -52,10 +52,6 @@ func signArtifactURL(h *Handler, id int64) string {
|
||||
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) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(Options{Dir: dir})
|
||||
@@ -771,7 +767,7 @@ func TestHandler_gcCache(t *testing.T) {
|
||||
Key: "test_key_3",
|
||||
Version: "test_version",
|
||||
Complete: true,
|
||||
UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
|
||||
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||
},
|
||||
Kept: false,
|
||||
@@ -1046,26 +1042,6 @@ func TestHandler_gcCacheInterval(t *testing.T) {
|
||||
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.
|
||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||
t.Helper()
|
||||
|
||||
@@ -39,8 +39,8 @@ const (
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
twirpInternal = "internal"
|
||||
twirpUnauthenticated = "unauthenticated"
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
)
|
||||
|
||||
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())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
@@ -83,30 +83,11 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
||||
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()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Owner: owner,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
@@ -126,7 +107,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,13 +119,6 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
defer db.Close()
|
||||
|
||||
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 {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
@@ -153,15 +127,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
h.twirpNotOK(w, r)
|
||||
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))
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
@@ -175,7 +147,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,11 +230,8 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
switch code {
|
||||
case twirpInternal:
|
||||
if code == twirpInternal {
|
||||
status = http.StatusInternalServerError
|
||||
case twirpUnauthenticated:
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -142,17 +141,6 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
}
|
||||
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()),
|
||||
"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{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
@@ -253,101 +241,3 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
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,7 +16,6 @@ type Cache struct {
|
||||
Key string `json:"key" boltholdIndex:"Key"`
|
||||
Version string `json:"version" boltholdIndex:"Version"`
|
||||
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"`
|
||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||
|
||||
@@ -5,8 +5,6 @@ package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -25,33 +23,15 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
|
||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||
// registered with, and the 404 the router would have written otherwise.
|
||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
http.NotFound(w, r)
|
||||
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, "/"))
|
||||
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 {
|
||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||
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)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||
@@ -64,9 +44,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
// directly and never through here.
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
if cred.InsecureTLS {
|
||||
@@ -75,27 +55,5 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||
@@ -4,8 +4,6 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json/v2"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -16,6 +14,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
||||
// still point at Gitea, and nothing else is proxied.
|
||||
func TestFrontResultsService(t *testing.T) {
|
||||
var gotHost, gotPath, gotProto string
|
||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -23,112 +23,34 @@ func TestFrontResultsService(t *testing.T) {
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer gitea.Close()
|
||||
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||
defer selfSigned.Close()
|
||||
|
||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||
const token = "forward-token"
|
||||
|
||||
noRedirect := func(token string) *http.Client {
|
||||
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) {
|
||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||
post := func(path string) int {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := noRedirect(token).Do(req)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body := map[string]string{}
|
||||
_ = json.UnmarshalRead(resp.Body, &body)
|
||||
return resp.StatusCode, body
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
status, body := post(rpc)
|
||||
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"])
|
||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||
"an unregistered token is forwarded nowhere")
|
||||
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
||||
|
||||
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, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||
assert.Equal(t, rpc, gotPath)
|
||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||
|
||||
gotPath = ""
|
||||
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = post("/api/v1/repos/owner/repo")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||
|
||||
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,10 +5,8 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -68,18 +66,6 @@ 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
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
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 {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -88,7 +74,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(list, wanted, 0o600)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -221,14 +220,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
||||
downloads(router, artifactPath)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: net.JoinHostPort(addr, port),
|
||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// run server
|
||||
go func() {
|
||||
logger.Infof("Start server on http://%s", server.Addr)
|
||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
|
||||
+114
-103
@@ -325,6 +325,33 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
||||
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
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -346,34 +373,25 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
|
||||
isOfflineMode := input.OfflineMode
|
||||
|
||||
fetchOptions := git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||
Force: true,
|
||||
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||
}
|
||||
if input.Token != "" {
|
||||
fetchOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
// fetch latest changes
|
||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||
|
||||
if input.InsecureSkipTLS { // For Gitea
|
||||
fetchOptions.InsecureSkipTLS = true
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
}
|
||||
|
||||
// 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.
|
||||
if isShallow(r) {
|
||||
shallow := isShallow(r)
|
||||
if shallow {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !isOfflineMode {
|
||||
err = r.FetchContext(ctx, &fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return err
|
||||
@@ -393,7 +411,23 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
rev, _ = refRevision(r, input.Ref)
|
||||
// At this point we need to know if it's a tag or a branch
|
||||
// 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 {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
@@ -405,13 +439,47 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
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 := ""
|
||||
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)"
|
||||
}
|
||||
|
||||
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{
|
||||
Hash: *hash,
|
||||
Force: true,
|
||||
@@ -420,42 +488,40 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||
// 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) {
|
||||
if input.Depth > 0 {
|
||||
if plumbing.IsHash(input.Ref) {
|
||||
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
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{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
@@ -464,65 +530,14 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
||||
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.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||
// 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) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
if _, err := r.Reference(tagRef, false); err == nil {
|
||||
@@ -533,9 +548,5 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||
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
|
||||
}
|
||||
+16
-131
@@ -6,6 +6,7 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,9 +24,6 @@ import (
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
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"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -438,7 +435,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||
}
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||
|
||||
@@ -464,43 +461,14 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
})
|
||||
|
||||
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) {
|
||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.NoFileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, unadvertisedSHA, 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"))
|
||||
// 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), "a SHA ref must not produce a shallow clone")
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
@@ -523,98 +491,6 @@ 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 {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
@@ -756,3 +632,12 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
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,8 +9,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -77,24 +75,6 @@ const (
|
||||
// fragment, missingContainerError composes it into the message every operation shares.
|
||||
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.
|
||||
type Info struct {
|
||||
ID string
|
||||
@@ -104,14 +84,13 @@ type Info struct {
|
||||
// HealthOutput is the last healthcheck probe's output.
|
||||
HealthOutput string
|
||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||
Mounts map[string]string // container path to its source on the daemon
|
||||
}
|
||||
|
||||
// Container for managing docker run containers
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
DumpLogs(ctx context.Context) error
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -173,47 +172,3 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
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,50 +6,16 @@ package container
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) {
|
||||
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")))
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
const (
|
||||
jobLabel = "com.gitea.runner.job"
|
||||
maxCreateBody = 8 << 20
|
||||
|
||||
dockerProxyProbeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||
)
|
||||
|
||||
func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
||||
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
|
||||
return nil
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
||||
defer cancel()
|
||||
cli, err := GetDockerClient(probeCtx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer cli.Close()
|
||||
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||
return nil
|
||||
}
|
||||
dir, err := filepath.Abs(os.TempDir())
|
||||
if err != nil {
|
||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||
return nil
|
||||
}
|
||||
daemonDir := dir
|
||||
seen, err := daemonSeesDir(probeCtx, cli, dir, daemonDir)
|
||||
if err == nil && !seen {
|
||||
if dir, daemonDir = runnerContainerWorkdir(probeCtx, cli); daemonDir != "" {
|
||||
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||
return nil
|
||||
}
|
||||
if !seen {
|
||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
proxy, err := StartDockerProxy(daemonSocket, dir, job)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||
return nil
|
||||
}
|
||||
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
|
||||
return proxy
|
||||
}
|
||||
|
||||
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
|
||||
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
|
||||
workdir, err := os.Getwd()
|
||||
hostname, hostnameErr := os.Hostname()
|
||||
if err != nil || hostnameErr != nil {
|
||||
return "", ""
|
||||
}
|
||||
self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{})
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
destination := ""
|
||||
for _, point := range self.Container.Mounts {
|
||||
if rel, err := filepath.Rel(point.Destination, workdir); err == nil && filepath.IsLocal(rel) && len(point.Destination) > len(destination) {
|
||||
destination, daemonDir = point.Destination, filepath.Join(point.Source, rel)
|
||||
}
|
||||
}
|
||||
return workdir, daemonDir
|
||||
}
|
||||
|
||||
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir,
|
||||
// which is what a job's proxy socket mounted from there needs.
|
||||
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
|
||||
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(marker.Name()); err != nil {
|
||||
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := marker.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(images.Items) == 0 {
|
||||
return false, errors.New("no image available for the docker proxy probe")
|
||||
}
|
||||
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
||||
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
||||
{Type: mount.TypeBind, Source: filepath.Join(daemonDir, filepath.Base(marker.Name())), Target: "/gitea-runner-probe", ReadOnly: true},
|
||||
}},
|
||||
})
|
||||
if cerrdefs.IsInvalidArgument(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||
defer cancel()
|
||||
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
|
||||
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||
info, err := os.Stat(daemonSocket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return nil, errors.New("docker daemon path is not a Unix socket")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, err := os.MkdirTemp(dir, "p-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
socket := filepath.Join(instance, "docker.sock")
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return nil, errors.Join(err, os.RemoveAll(instance))
|
||||
}
|
||||
if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
|
||||
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
|
||||
}
|
||||
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
||||
}
|
||||
transport := &http.Transport{DialContext: dial}
|
||||
forward := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.Out.URL.Scheme = "http"
|
||||
r.Out.URL.Host = "docker"
|
||||
},
|
||||
Transport: transport,
|
||||
}
|
||||
proxy := &DockerProxy{Socket: socket}
|
||||
streams, cancelStreams := context.WithCancel(context.Background())
|
||||
creates, cancelCreates := context.WithCancel(context.Background())
|
||||
var admission sync.Mutex
|
||||
var handlers sync.WaitGroup
|
||||
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
|
||||
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
|
||||
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
admission.Lock()
|
||||
if streams.Err() != nil {
|
||||
admission.Unlock()
|
||||
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
handlers.Add(1)
|
||||
admission.Unlock()
|
||||
defer handlers.Done()
|
||||
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
|
||||
parent, lifetime := r.Context(), streams
|
||||
if creating {
|
||||
parent, lifetime = context.WithoutCancel(parent), creates
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
stop := context.AfterFunc(lifetime, func() {
|
||||
cancel()
|
||||
if !creating {
|
||||
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
defer stop()
|
||||
r = r.WithContext(ctx)
|
||||
if creating {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
||||
mounts, _ := proxy.mounts.Load().(map[string]string)
|
||||
if err := rewriteCreate(r, job, mounts); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
} else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
|
||||
tunnel(w, r, dial, forward)
|
||||
return
|
||||
}
|
||||
forward.ServeHTTP(w, r)
|
||||
})}
|
||||
served := make(chan struct{})
|
||||
go func() {
|
||||
defer close(served)
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
proxy.close = func(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
admission.Lock()
|
||||
listenerErr := listener.Close()
|
||||
cancelStreams()
|
||||
admission.Unlock()
|
||||
<-served
|
||||
shutdownErr := server.Shutdown(ctx)
|
||||
cancelCreates()
|
||||
serverErr := server.Close()
|
||||
handlers.Wait()
|
||||
transport.CloseIdleConnections()
|
||||
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func rewriteCreate(r *http.Request, job string, mounts map[string]string) error {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
body = []byte("{}")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
var config struct{ Labels map[string]string }
|
||||
if err := json.Unmarshal(body, &fields); err != nil {
|
||||
return fmt.Errorf("invalid create request: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &config); err != nil {
|
||||
return fmt.Errorf("invalid create labels: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
fields = make(map[string]json.RawMessage)
|
||||
}
|
||||
if len(mounts) > 0 && !hasAmbiguousFields(body) {
|
||||
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
|
||||
}
|
||||
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
|
||||
return strings.EqualFold(name, "Labels")
|
||||
})
|
||||
if config.Labels == nil {
|
||||
config.Labels = make(map[string]string)
|
||||
}
|
||||
config.Labels[jobLabel] = job
|
||||
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
||||
return err
|
||||
}
|
||||
if body, err = json.Marshal(fields); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
r.ContentLength = int64(len(body))
|
||||
r.TransferEncoding = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
|
||||
cleanedSource := func(source string) string {
|
||||
cleaned := path.Clean(source)
|
||||
if target := jobMount(cleaned, mounts); mounts[target] != "" {
|
||||
return mounts[target] + cleaned[len(target):]
|
||||
}
|
||||
return source
|
||||
}
|
||||
spelledSource := func(source string) string { // dockerd checks these as spelled
|
||||
target := jobMount(path.Clean(source), mounts)
|
||||
if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) {
|
||||
return mounts[target] + rest
|
||||
}
|
||||
return cleanedSource(source)
|
||||
}
|
||||
switch kind {
|
||||
case "volumes":
|
||||
var driver string
|
||||
var options map[string]any
|
||||
decodeField(fields, "Driver", &driver)
|
||||
if key := decodeField(fields, "DriverOpts", &options); options != nil {
|
||||
translateDevice(driver, options, spelledSource)
|
||||
if encoded, err := json.Marshal(options); err == nil {
|
||||
fields[key] = encoded
|
||||
}
|
||||
}
|
||||
case "containers":
|
||||
var hostConfig map[string]any
|
||||
key := decodeField(fields, "HostConfig", &hostConfig)
|
||||
binds, _ := field(hostConfig, "Binds").([]any)
|
||||
for i, bind := range binds {
|
||||
bind, _ := bind.(string)
|
||||
source, target, _ := strings.Cut(bind, ":")
|
||||
if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") {
|
||||
binds[i] = translated + ":" + target
|
||||
}
|
||||
}
|
||||
specs, _ := field(hostConfig, "Mounts").([]any)
|
||||
for _, spec := range specs {
|
||||
spec, _ := spec.(map[string]any)
|
||||
switch field(spec, "Type") {
|
||||
case "bind":
|
||||
if source, ok := field(spec, "Source").(string); ok {
|
||||
spec["Source"] = spelledSource(source)
|
||||
}
|
||||
case "volume":
|
||||
volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any)
|
||||
driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any)
|
||||
translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource)
|
||||
}
|
||||
}
|
||||
if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil {
|
||||
fields[key] = encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeField(fields map[string]json.RawMessage, name string, value any) string {
|
||||
for key, raw := range fields {
|
||||
if strings.EqualFold(key, name) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
_ = decoder.Decode(value) // wrong types stay unset for dockerd to reject
|
||||
return key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func translateDevice(driver, options any, translate func(string) string) {
|
||||
optionMap, _ := options.(map[string]any)
|
||||
device, ok := optionMap["device"].(string)
|
||||
flags, _ := optionMap["o"].(string)
|
||||
tokens := strings.Split(flags, ",")
|
||||
local := driver == nil || driver == "" || driver == "local"
|
||||
if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") {
|
||||
optionMap["device"] = translate(device)
|
||||
}
|
||||
}
|
||||
|
||||
// field also renames the matched key to name.
|
||||
func field(object map[string]any, name string) any {
|
||||
for key, value := range object {
|
||||
if strings.EqualFold(key, name) {
|
||||
delete(object, key)
|
||||
object[name] = value
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
requestFields = []string{"hostconfig", "driver", "driveropts"}
|
||||
asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters
|
||||
)
|
||||
|
||||
// dockerd settles repeated names by order, which re-encoding loses.
|
||||
func hasAmbiguousFields(body []byte) bool {
|
||||
type frame struct {
|
||||
names map[string]bool
|
||||
key string
|
||||
expectKey bool
|
||||
nested bool
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
stack := []*frame{{}}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return !errors.Is(err, io.EOF)
|
||||
}
|
||||
top := stack[len(stack)-1]
|
||||
if name, ok := token.(string); ok && top.expectKey {
|
||||
top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false
|
||||
if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) {
|
||||
return true
|
||||
}
|
||||
top.names[top.key] = true
|
||||
continue
|
||||
}
|
||||
top.expectKey = top.names != nil
|
||||
nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key)
|
||||
switch token {
|
||||
case json.Delim('{'):
|
||||
stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested})
|
||||
case json.Delim('['):
|
||||
stack = append(stack, &frame{nested: nested})
|
||||
case json.Delim('}'), json.Delim(']'):
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jobMount returns "" also for a path already naming a daemon source.
|
||||
func jobMount(source string, mounts map[string]string) string {
|
||||
target := ""
|
||||
for destination, daemonSource := range mounts {
|
||||
if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) {
|
||||
return ""
|
||||
}
|
||||
if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) {
|
||||
target = destination
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
type dockerProxyConnKey struct{}
|
||||
|
||||
type dockerProxyResponse struct {
|
||||
response *http.Response
|
||||
}
|
||||
|
||||
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
|
||||
return r.response, nil
|
||||
}
|
||||
|
||||
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
||||
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
|
||||
upstream, err := dial(r.Context(), "", "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
|
||||
defer stop()
|
||||
if err := r.Write(upstream); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
reader := bufio.NewReader(upstream)
|
||||
var response *http.Response
|
||||
for {
|
||||
response, err = http.ReadResponse(reader, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
|
||||
break
|
||||
}
|
||||
maps.Copy(w.Header(), response.Header)
|
||||
w.WriteHeader(response.StatusCode)
|
||||
clear(w.Header())
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
defer func() {
|
||||
_ = upstream.Close()
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
|
||||
ordinary := *forward
|
||||
ordinary.Transport = dockerProxyResponse{response: response}
|
||||
ordinary.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
downstream, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer downstream.Close()
|
||||
if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
|
||||
return
|
||||
}
|
||||
if err := response.Header.Write(buffered); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := buffered.WriteString("\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := buffered.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
|
||||
_ = upstream.Close()
|
||||
} else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||
_ = writer.CloseWrite()
|
||||
} else {
|
||||
_ = upstream.Close()
|
||||
}
|
||||
}()
|
||||
_, _ = io.Copy(downstream, reader)
|
||||
_ = downstream.Close()
|
||||
_ = upstream.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func RemoveDockerJobResources(ctx context.Context, job string) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
return removeLabelled(ctx, cli, job)
|
||||
}
|
||||
|
||||
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
||||
logger := common.Logger(ctx)
|
||||
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
||||
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||
errs := []error{err}
|
||||
for _, c := range containers.Items {
|
||||
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
||||
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
||||
}
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||
errs = append(errs, err)
|
||||
for _, n := range networks.Items {
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it
|
||||
logger.Infof("keeping network %s, a swarm service still uses it", n.Name)
|
||||
} else if err != nil && !cerrdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
}
|
||||
}
|
||||
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
||||
errs = append(errs, err)
|
||||
for _, v := range volumes.Items {
|
||||
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
+31
-50
@@ -14,7 +14,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -139,12 +138,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
||||
).IfNot(common.Dryrun)
|
||||
}
|
||||
|
||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return common.NewPipelineExecutor(
|
||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
||||
cr.connect(),
|
||||
cr.find(),
|
||||
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
|
||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
||||
func(ctx context.Context) error {
|
||||
// If this fails, then folders have wrong permissions on non root container
|
||||
if cr.UID != 0 || cr.GID != 0 {
|
||||
@@ -253,31 +252,6 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
||||
ID: inspect.ID,
|
||||
Health: HealthNone,
|
||||
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 {
|
||||
@@ -510,11 +484,14 @@ func (cr *containerReference) remove() common.Executor {
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
if cerrdefs.IsConflict(err) {
|
||||
err = cr.waitForRemoval(ctx, idOrName)
|
||||
}
|
||||
if err != nil && !cerrdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
|
||||
switch {
|
||||
case cerrdefs.IsConflict(err):
|
||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
||||
// references and the network endpoint only once it finishes
|
||||
cr.waitForRemoval(ctx, idOrName)
|
||||
case err != nil && !cerrdefs.IsNotFound(err):
|
||||
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
||||
return nil // keep the id, the container is still there for a later Remove()
|
||||
}
|
||||
|
||||
logger.Debugf("Removed container: %v", idOrName)
|
||||
@@ -523,7 +500,7 @@ func (cr *containerReference) remove() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
||||
// per container, against the one minute the post-job executor allows for the whole
|
||||
// cleanup, so a job with several services can spend most of that budget here
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
@@ -533,13 +510,18 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
||||
Condition: container.WaitConditionRemoved,
|
||||
})
|
||||
select {
|
||||
case result := <-waitResult.Result:
|
||||
if result.Error != nil {
|
||||
return errors.New(result.Error.Message)
|
||||
}
|
||||
return nil
|
||||
case err := <-waitResult.Error:
|
||||
return err
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
case <-ctx.Done():
|
||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
||||
// behind or its goroutine parks on the send for the lifetime of the process
|
||||
go func() {
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
}
|
||||
}()
|
||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,7 +940,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||
@@ -999,10 +981,9 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
}
|
||||
|
||||
fc := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
UID: cr.UID,
|
||||
@@ -1291,15 +1272,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []*glob.Pattern
|
||||
host []*glob.Pattern
|
||||
named []glob.Glob
|
||||
host []glob.Glob
|
||||
}
|
||||
|
||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||
logger := common.Logger(ctx)
|
||||
ret := validVolumeMatcher{
|
||||
named: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||
host: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
||||
}
|
||||
|
||||
for _, v := range validVolumes {
|
||||
|
||||
@@ -345,22 +345,18 @@ func TestDockerWaitFailure(t *testing.T) {
|
||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||
// be logged as one.
|
||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
removeFailure := errors.New("driver failed to remove root filesystem")
|
||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
waitErr error
|
||||
wantErr error
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
wantFailure bool
|
||||
}{
|
||||
{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: "removed cleanly", err: nil},
|
||||
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
@@ -370,24 +366,21 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
if tc.wantWait {
|
||||
removed := make(chan container.WaitResponse, 1)
|
||||
waitErrors := make(chan error, 1)
|
||||
if tc.waitErr == nil {
|
||||
removed <- container.WaitResponse{}
|
||||
} else {
|
||||
waitErrors <- tc.waitErr
|
||||
}
|
||||
removed <- container.WaitResponse{}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
||||
if tc.wantErr != nil {
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
assert.Equal(t, "abc", cr.id)
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -478,7 +471,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
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("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
|
||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
@@ -808,31 +801,6 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
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) {
|
||||
|
||||
@@ -9,7 +9,6 @@ package container
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
@@ -18,10 +17,6 @@ import (
|
||||
"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
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||
@@ -34,18 +29,6 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
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
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -94,11 +77,3 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
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,67 +8,40 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, item := range volumes.Items {
|
||||
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)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vol := range list.Items {
|
||||
if vol.Name == volumeName {
|
||||
return removeExecutor(volumeName, force)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume not found - do nothing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeExecutor(volume string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("docker volume rm %s", volume)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
@@ -80,10 +53,7 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||
return nil
|
||||
}
|
||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
@@ -110,10 +110,9 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGi
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
},
|
||||
|
||||
@@ -47,32 +47,10 @@ func TestCopyDir(t *testing.T) {
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type macOSVM struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Running bool `json:"running"`
|
||||
AccessedAt string `json:"accessed_at"`
|
||||
}
|
||||
|
||||
// CleanupOrphanMacOSVMs deletes local macOS VMs left behind by this runner that
|
||||
// have not been accessed since cutoff.
|
||||
func CleanupOrphanMacOSVMs(ctx context.Context, executorPath, uuid string, cutoff time.Time) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := exec.CommandContext(ctx, executorPath, "list", "--format", "json").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list macOS VMs: %w", err)
|
||||
}
|
||||
|
||||
var vms []macOSVM
|
||||
if err := json.Unmarshal(output, &vms); err != nil {
|
||||
return fmt.Errorf("parse macOS VM list: %w", err)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, name := range macOSVMsToDelete(vms, uuid, cutoff) {
|
||||
if err := exec.CommandContext(ctx, executorPath, "destroy", name).Run(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("destroy macOS VM %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func macOSVMsToDelete(vms []macOSVM, uuid string, cutoff time.Time) []string {
|
||||
prefix := "GITEA-MACOS-VM-" + uuid + "-"
|
||||
var stale []string
|
||||
for _, vm := range vms {
|
||||
if vm.Running || !strings.HasPrefix(vm.Name, prefix) {
|
||||
continue
|
||||
}
|
||||
accessed, err := parseMacOSVMTime(vm.AccessedAt)
|
||||
if err != nil || accessed.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, vm.Name)
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
func parseMacOSVMTime(raw string) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, errors.New("empty access time")
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return time.Parse(time.RFC3339, raw)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMacOSVMsToDelete(t *testing.T) {
|
||||
cutoff := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
vms := []macOSVM{
|
||||
{Name: "GITEA-MACOS-VM-runner-1-stale", AccessedAt: "2026-09-04T10:00:00Z"},
|
||||
{Name: "GITEA-MACOS-VM-runner-1-running", AccessedAt: "2026-09-04T10:00:00Z", Running: true},
|
||||
{Name: "GITEA-MACOS-VM-runner-1-fresh", AccessedAt: "2026-09-05T11:00:00Z"},
|
||||
{Name: "GITEA-MACOS-VM-runner-2-stale", AccessedAt: "2026-09-04T10:00:00Z"},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"GITEA-MACOS-VM-runner-1-stale"}, macOSVMsToDelete(vms, "runner-1", cutoff))
|
||||
}
|
||||
|
||||
func TestParseMacOSVMTime(t *testing.T) {
|
||||
parsed, err := parseMacOSVMTime("2026-09-05T10:00:00Z")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), parsed)
|
||||
|
||||
parsed, err = parseMacOSVMTime("2026-09-05T10:00:00.123456789Z")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 123456789, time.UTC), parsed)
|
||||
|
||||
_, err = parseMacOSVMTime("")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMacOSVMBootTimeout = 5 * time.Minute
|
||||
macOSVMReadyPollInterval = 500 * time.Millisecond
|
||||
macOSVMStopWaitTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// MacOSVMEnvironment runs workflow steps in an ephemeral macOS VM.
|
||||
type MacOSVMEnvironment struct {
|
||||
Path string // guest scratch directory
|
||||
TmpDir string // guest temp directory
|
||||
ToolCache string // guest tool cache directory
|
||||
Workdir string // host-side workspace path
|
||||
GuestWorkdir string // guest-side workspace path
|
||||
ActPath string // guest act directory
|
||||
|
||||
Image string
|
||||
VMName string
|
||||
ExecutorPath string
|
||||
CPU int
|
||||
Memory int
|
||||
BootTimeout time.Duration
|
||||
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
|
||||
executorPath string
|
||||
running bool
|
||||
runCmd *exec.Cmd
|
||||
runDone chan struct{}
|
||||
runErr error
|
||||
}
|
||||
|
||||
// MacOSVMEnvironmentInput is the input for NewMacOSVMEnvironment.
|
||||
type MacOSVMEnvironmentInput struct {
|
||||
Path string
|
||||
TmpDir string
|
||||
ToolCache string
|
||||
Workdir string
|
||||
GuestWorkdir string
|
||||
ActPath string
|
||||
Image string
|
||||
VMName string
|
||||
ExecutorPath string
|
||||
CPU int
|
||||
Memory int
|
||||
BootTimeout time.Duration
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
func NewMacOSVMEnvironment(input MacOSVMEnvironmentInput) (*MacOSVMEnvironment, error) {
|
||||
if input.Image == "" {
|
||||
return nil, errors.New("macOS VM image is required")
|
||||
}
|
||||
if input.VMName == "" {
|
||||
return nil, errors.New("macOS VM name is required")
|
||||
}
|
||||
if input.BootTimeout <= 0 {
|
||||
input.BootTimeout = defaultMacOSVMBootTimeout
|
||||
}
|
||||
if input.ExecutorPath == "" {
|
||||
input.ExecutorPath = "runner-executor-macos"
|
||||
}
|
||||
return &MacOSVMEnvironment{
|
||||
Path: input.Path,
|
||||
TmpDir: input.TmpDir,
|
||||
ToolCache: input.ToolCache,
|
||||
Workdir: input.Workdir,
|
||||
GuestWorkdir: input.GuestWorkdir,
|
||||
ActPath: input.ActPath,
|
||||
Image: input.Image,
|
||||
VMName: input.VMName,
|
||||
ExecutorPath: input.ExecutorPath,
|
||||
CPU: input.CPU,
|
||||
Memory: input.Memory,
|
||||
BootTimeout: input.BootTimeout,
|
||||
Stdout: input.Stdout,
|
||||
Stderr: input.Stderr,
|
||||
executorPath: input.ExecutorPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ ExecutionsEnvironment = &MacOSVMEnvironment{}
|
||||
|
||||
func (e *MacOSVMEnvironment) Create(_, _ []string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
// A previous run can leave the deterministic job VM behind. Destroy it first.
|
||||
_ = e.runExecutor(ctx, nil, io.Discard, io.Discard, "destroy", e.VMName)
|
||||
|
||||
args := []string{"provision", e.Image, e.VMName}
|
||||
if e.CPU > 0 {
|
||||
args = append(args, "--cpu", strconv.Itoa(e.CPU))
|
||||
}
|
||||
if e.Memory > 0 {
|
||||
args = append(args, "--memory", strconv.Itoa(e.Memory))
|
||||
}
|
||||
return e.runExecutor(ctx, nil, e.Stdout, e.Stderr, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
for _, file := range files {
|
||||
dest := path.Join(destPath, file.Name)
|
||||
script := `mkdir -p "$1" && cat > "$2" && chmod "$3" "$2"`
|
||||
mode := file.Mode
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
args := []string{"exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", path.Dir(dest), dest, strconv.FormatInt(mode, 8)}
|
||||
if err := e.runExecutor(ctx, strings.NewReader(file.Body), e.Stdout, e.Stderr, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
err := e.writeTar(ctx, pipeWriter, srcPath, useGitIgnore)
|
||||
_ = pipeWriter.CloseWithError(err)
|
||||
writeDone <- err
|
||||
}()
|
||||
|
||||
script := `export COPYFILE_DISABLE=1; mkdir -p "$1" && /usr/bin/tar -xf - -C "$1"`
|
||||
cmdErr := e.runExecutor(ctx, pipeReader, e.Stdout, e.Stderr, "exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", destPath)
|
||||
_ = pipeReader.Close()
|
||||
writeErr := <-writeDone
|
||||
return errors.Join(cmdErr, writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) writeTar(ctx context.Context, writer io.Writer, srcPath string, useGitIgnore bool) error {
|
||||
tarWriter := tar.NewWriter(writer)
|
||||
defer tarWriter.Close()
|
||||
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
|
||||
var ignorer gitignore.Matcher
|
||||
if useGitIgnore {
|
||||
patterns, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("Error loading .gitignore: %v", err)
|
||||
}
|
||||
ignorer = gitignore.NewMatcher(patterns)
|
||||
}
|
||||
|
||||
collector := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: &filecollector.TarCollector{TarWriter: tarWriter},
|
||||
}
|
||||
return filepath.Walk(srcPath, collector.CollectFiles(ctx, []string{}))
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, e.executorPath, "exec", e.VMName, "/usr/bin/env", "COPYFILE_DISABLE=1", "/usr/bin/tar", "-cf", "-", "-C", "/", strings.TrimPrefix(srcPath, "/"))
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Stderr = e.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &macOSVMArchiveReader{ReadCloser: stdout, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
type macOSVMArchiveReader struct {
|
||||
io.ReadCloser
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (r *macOSVMArchiveReader) Close() error {
|
||||
closeErr := r.ReadCloser.Close()
|
||||
return errors.Join(closeErr, r.cmd.Wait())
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Inspect(ctx context.Context) (*Info, error) {
|
||||
if common.Dryrun(ctx) {
|
||||
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
state := "exited"
|
||||
if e.running {
|
||||
state = StateRunning
|
||||
}
|
||||
return &Info{ID: e.VMName, State: state, Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) DumpLogs(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Pull(forcePull bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Start(bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command(e.executorPath, "start", "--no-graphics", e.VMName)
|
||||
cmd.Stdout = e.Stdout
|
||||
cmd.Stderr = e.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
e.runCmd = cmd
|
||||
e.runDone = make(chan struct{})
|
||||
go func() {
|
||||
e.runErr = cmd.Wait()
|
||||
close(e.runDone)
|
||||
}()
|
||||
e.running = true
|
||||
return e.waitReady(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) waitReady(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, e.BootTimeout)
|
||||
defer cancel()
|
||||
|
||||
var lastErr error
|
||||
for {
|
||||
if err := e.runExecutor(ctx, nil, io.Discard, io.Discard, "exec", e.VMName, "/usr/bin/true"); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("macOS VM %s did not become ready: %w", e.VMName, lastErr)
|
||||
case <-e.runDone:
|
||||
if e.runErr != nil {
|
||||
return fmt.Errorf("macOS VM start exited before the VM became ready: %w", e.runErr)
|
||||
}
|
||||
return errors.New("macOS VM start exited before the VM became ready")
|
||||
case <-time.After(macOSVMReadyPollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
err := e.runExecutor(ctx, nil, e.Stdout, e.Stderr, macOSVMExecArgs(e.VMName, command, env, e.resolveWorkdir(workdir))...)
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return ExitCodeError(exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func macOSVMExecArgs(vmName string, command []string, env map[string]string, workdir string) []string {
|
||||
args := []string{"exec", vmName, "/usr/bin/env"}
|
||||
for _, key := range slices.Sorted(maps.Keys(env)) {
|
||||
args = append(args, key+"="+env[key])
|
||||
}
|
||||
args = append(args, "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", workdir)
|
||||
return append(args, command...)
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) resolveWorkdir(workdir string) string {
|
||||
switch {
|
||||
case workdir == "":
|
||||
return e.GuestWorkdir
|
||||
case strings.HasPrefix(workdir, "/"):
|
||||
return workdir
|
||||
default:
|
||||
return path.Join(e.GuestWorkdir, workdir)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) UpdateFromImageEnv(*map[string]string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if e.runCmd != nil && e.runDone != nil {
|
||||
select {
|
||||
case <-e.runDone:
|
||||
default:
|
||||
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "stop", e.VMName, "--timeout", "20"); err != nil {
|
||||
common.Logger(ctx).Debugf("macOS VM stop failed for VM %s: %v", e.VMName, err)
|
||||
}
|
||||
select {
|
||||
case <-e.runDone:
|
||||
case <-time.After(macOSVMStopWaitTimeout):
|
||||
if e.runCmd.Process != nil {
|
||||
_ = e.runCmd.Process.Kill()
|
||||
}
|
||||
common.Logger(ctx).Warnf("timed out waiting for macOS VM start to exit for VM %s", e.VMName)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.running = false
|
||||
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "destroy", e.VMName); err != nil {
|
||||
common.Logger(ctx).Warnf("failed to destroy macOS VM %s: %v", e.VMName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) Close() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
if e.runCmd != nil && e.runCmd.Process != nil {
|
||||
_ = e.runCmd.Process.Kill()
|
||||
}
|
||||
e.runCmd = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||
oldOut := e.Stdout
|
||||
oldErr := e.Stderr
|
||||
e.Stdout = stdout
|
||||
e.Stderr = stderr
|
||||
return oldOut, oldErr
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) ToContainerPath(hostPath string) string {
|
||||
if filepath.Clean(hostPath) == filepath.Clean(e.Workdir) {
|
||||
return e.GuestWorkdir
|
||||
}
|
||||
if rel, err := filepath.Rel(e.Workdir, hostPath); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return path.Join(e.GuestWorkdir, filepath.ToSlash(rel))
|
||||
}
|
||||
return filepath.ToSlash(hostPath)
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) GetActPath() string {
|
||||
return e.ActPath
|
||||
}
|
||||
|
||||
func (*MacOSVMEnvironment) GetPathVariableName() string {
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func (*MacOSVMEnvironment) DefaultPathVariable() string {
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
|
||||
func (*MacOSVMEnvironment) JoinPathVariable(paths ...string) string {
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) GetRunnerContext(context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "macOS",
|
||||
"arch": goArchToActionArch(runtime.GOARCH),
|
||||
"temp": e.TmpDir,
|
||||
"tool_cache": e.ToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (*MacOSVMEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *MacOSVMEnvironment) runExecutor(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
|
||||
if stdout == nil {
|
||||
stdout = e.Stdout
|
||||
}
|
||||
if stderr == nil {
|
||||
stderr = e.Stderr
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, e.executorPath, args...)
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("macOS VM executor %s: %w", args[0], err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build macos_vm_integration
|
||||
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMacOSVMEnvironmentIntegration(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("macOS VMs run on macOS")
|
||||
}
|
||||
if _, err := exec.LookPath("runner-executor-macos"); err != nil {
|
||||
t.Skip("runner-executor-macos executable not found")
|
||||
}
|
||||
image := os.Getenv("MACOS_VM_TEST_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("set MACOS_VM_TEST_IMAGE to a macOS VM image to run this test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
vmName := fmt.Sprintf("gitea-runner-test-%d", time.Now().UnixNano())
|
||||
base := "/Users/admin/runner"
|
||||
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
|
||||
base = t.TempDir()
|
||||
}
|
||||
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||
Path: base + "/scratch/" + vmName,
|
||||
TmpDir: base + "/scratch/" + vmName + "/tmp",
|
||||
ToolCache: base + "/scratch/" + vmName + "/tool_cache",
|
||||
Workdir: t.TempDir(),
|
||||
GuestWorkdir: base + "/workspace/" + vmName,
|
||||
ActPath: base + "/scratch/" + vmName + "/act",
|
||||
Image: image,
|
||||
VMName: vmName,
|
||||
ExecutorPath: "runner-executor-macos",
|
||||
BootTimeout: 10 * time.Minute,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = env.Remove()(context.Background())
|
||||
})
|
||||
|
||||
require.NoError(t, env.Create(nil, nil)(ctx))
|
||||
require.NoError(t, env.Start(false)(ctx))
|
||||
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
|
||||
require.NoError(t, os.MkdirAll(env.GuestWorkdir, 0o755))
|
||||
}
|
||||
require.NoError(t, env.Exec([]string{"/bin/mkdir", "-p", env.GuestWorkdir}, nil, "", "/")(ctx))
|
||||
|
||||
require.NoError(t, env.Copy("/tmp", &FileEntry{
|
||||
Name: "gitea-runner-macos.txt",
|
||||
Mode: 0o644,
|
||||
Body: "hello from macos-vm",
|
||||
})(ctx))
|
||||
|
||||
archive, err := env.GetContainerArchive(ctx, "/tmp/gitea-runner-macos.txt")
|
||||
require.NoError(t, err)
|
||||
body := readFirstTarFile(t, archive)
|
||||
require.NoError(t, archive.Close())
|
||||
require.Equal(t, "hello from macos-vm", body)
|
||||
|
||||
var output bytes.Buffer
|
||||
oldOut, oldErr := env.ReplaceLogWriter(&output, &output)
|
||||
defer env.ReplaceLogWriter(oldOut, oldErr)
|
||||
require.NoError(t, env.Exec([]string{"/usr/bin/uname", "-s"}, nil, "", "")(ctx))
|
||||
require.Contains(t, output.String(), "Darwin")
|
||||
|
||||
require.NoError(t, env.Remove()(ctx))
|
||||
}
|
||||
|
||||
func readFirstTarFile(t *testing.T, archive io.Reader) string {
|
||||
t.Helper()
|
||||
reader := tar.NewReader(archive)
|
||||
header, err := reader.Next()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, header.Name, "gitea-runner-macos.txt")
|
||||
body, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewMacOSVMEnvironment(t *testing.T) {
|
||||
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
VMName: "job-vm",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", env.Image)
|
||||
assert.Equal(t, "job-vm", env.VMName)
|
||||
assert.Equal(t, defaultMacOSVMBootTimeout, env.BootTimeout)
|
||||
assert.Equal(t, "runner-executor-macos", env.executorPath)
|
||||
|
||||
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{VMName: "job-vm"})
|
||||
require.Error(t, err)
|
||||
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{Image: "macos"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMacOSVMExecArgs(t *testing.T) {
|
||||
args := macOSVMExecArgs("vm", []string{"bash", "-c", "echo hi"}, map[string]string{"B": "two", "A": "one"}, "/Users/admin/work")
|
||||
assert.Equal(t, []string{
|
||||
"exec", "vm", "/usr/bin/env",
|
||||
"A=one", "B=two",
|
||||
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "/Users/admin/work",
|
||||
"bash", "-c", "echo hi",
|
||||
}, args)
|
||||
|
||||
args = macOSVMExecArgs("vm", []string{"true"}, nil, "")
|
||||
assert.Equal(t, []string{
|
||||
"exec", "vm", "/usr/bin/env",
|
||||
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "",
|
||||
"true",
|
||||
}, args)
|
||||
}
|
||||
|
||||
func TestMacOSVMEnvironmentResolveWorkdir(t *testing.T) {
|
||||
env := &MacOSVMEnvironment{GuestWorkdir: "/Users/admin/work"}
|
||||
assert.Equal(t, "/Users/admin/work", env.resolveWorkdir(""))
|
||||
assert.Equal(t, "/Users/admin/work/sub", env.resolveWorkdir("sub"))
|
||||
assert.Equal(t, "/tmp", env.resolveWorkdir("/tmp"))
|
||||
}
|
||||
|
||||
func TestMacOSVMEnvironmentToContainerPath(t *testing.T) {
|
||||
env := &MacOSVMEnvironment{Workdir: "/workspace/owner/repo", GuestWorkdir: "/Users/admin/work/owner/repo"}
|
||||
assert.Equal(t, "/Users/admin/work/owner/repo", env.ToContainerPath("/workspace/owner/repo"))
|
||||
assert.Equal(t, "/Users/admin/work/owner/repo/sub", env.ToContainerPath("/workspace/owner/repo/sub"))
|
||||
assert.Equal(t, "/elsewhere", env.ToContainerPath("/elsewhere"))
|
||||
}
|
||||
|
||||
func TestMacOSVMEnvironmentGetRunnerContext(t *testing.T) {
|
||||
env := &MacOSVMEnvironment{
|
||||
TmpDir: "/Users/admin/scratch/tmp",
|
||||
ToolCache: "/Users/admin/scratch/tool_cache",
|
||||
}
|
||||
ctx := env.GetRunnerContext(context.Background())
|
||||
assert.Equal(t, "macOS", ctx["os"])
|
||||
assert.Equal(t, goArchToActionArch(runtime.GOARCH), ctx["arch"])
|
||||
assert.Equal(t, "/Users/admin/scratch/tmp", ctx["temp"])
|
||||
assert.Equal(t, "/Users/admin/scratch/tool_cache", ctx["tool_cache"])
|
||||
}
|
||||
|
||||
func TestMacOSVMEnvironmentReplaceLogWriter(t *testing.T) {
|
||||
oldOut := io.Discard
|
||||
oldErr := io.Discard
|
||||
env := &MacOSVMEnvironment{Stdout: oldOut, Stderr: oldErr}
|
||||
newOut, newErr := env.ReplaceLogWriter(io.Discard, io.Discard)
|
||||
assert.Equal(t, oldOut, newOut)
|
||||
assert.Equal(t, oldErr, newErr)
|
||||
}
|
||||
|
||||
func TestMacOSVMEnvironmentDefaultPathVariable(t *testing.T) {
|
||||
env := &MacOSVMEnvironment{}
|
||||
assert.Equal(t, "PATH", env.GetPathVariableName())
|
||||
assert.Equal(t, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", env.DefaultPathVariable())
|
||||
assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
|
||||
assert.False(t, env.IsEnvironmentCaseInsensitive())
|
||||
}
|
||||
@@ -94,11 +94,10 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
||||
}
|
||||
|
||||
type FileCollector struct {
|
||||
Ignorer gitignore.Matcher
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
SkipGitDir bool
|
||||
Handler Handler
|
||||
Ignorer gitignore.Matcher
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
func openGitIndex(path string) (*index.Index, error) {
|
||||
@@ -125,12 +124,6 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
||||
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
||||
return nil
|
||||
}
|
||||
if fc.SkipGitDir && split[len(split)-1] == ".git" {
|
||||
if fi.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var entry *index.Entry
|
||||
if i != nil {
|
||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
||||
|
||||
@@ -147,14 +147,14 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
|
||||
if !rc.Config.NoActionPatch {
|
||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
||||
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
||||
}
|
||||
|
||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func TestCompositeActionParity(t *testing.T) {
|
||||
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
strategy := &model.Strategy{MaxParallelString: "3"}
|
||||
strategy := &model.Strategy{MaxParallel: 3}
|
||||
parent := &RunContext{
|
||||
Config: &Config{},
|
||||
Matrix: map[string]any{"os": "linux"},
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestActionRunner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
|
||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
|
||||
|
||||
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
||||
for k, v := range tt.expectedEnv {
|
||||
@@ -337,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
copyEntered := make(chan struct{})
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
|
||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
||||
close(copyEntered)
|
||||
<-releaseCopy
|
||||
return nil
|
||||
|
||||
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
|
||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
args := cm.Called(destPath, srcPath, useGitIgnore)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -27,29 +27,6 @@ import (
|
||||
"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
|
||||
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
|
||||
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
||||
@@ -64,7 +41,8 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
if rc.Run != nil {
|
||||
job := rc.Run.Job()
|
||||
if job != nil && job.Strategy != nil {
|
||||
setStrategyContext(strategy, job.Strategy)
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
@@ -140,7 +118,8 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
||||
job := rc.Run.Job()
|
||||
strategy := make(map[string]any)
|
||||
if job.Strategy != nil {
|
||||
setStrategyContext(strategy, job.Strategy)
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
@@ -354,16 +333,12 @@ func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
||||
}
|
||||
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()
|
||||
|
||||
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
|
||||
for name, input := range config.Inputs {
|
||||
value, eval, label := callerWith[name], callerEval, "input"
|
||||
value, eval, label := callerJob.With[name], callerEval, "input"
|
||||
if value == nil {
|
||||
value, eval, label = input.Default, calleeEval(), "the default of input"
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
||||
out any
|
||||
errMesg string
|
||||
}{
|
||||
{" 1 ", 1.0, ""},
|
||||
{" 1 ", 1, ""},
|
||||
// {"1 + 3", "4", ""},
|
||||
// {"(1 + 3) * -2", "-8", ""},
|
||||
{"'my text'", "my text", ""},
|
||||
@@ -266,6 +266,7 @@ func TestInterpolate(t *testing.T) {
|
||||
{"${{ null }}", ""},
|
||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||
{"${{ 1", "${{ 1"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
@@ -276,7 +277,7 @@ func TestInterpolate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
|
||||
_, err := ee.Interpolate(context.Background(), in)
|
||||
assert.Error(t, err, in)
|
||||
}
|
||||
@@ -359,32 +360,6 @@ on:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -263,19 +263,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
return err
|
||||
})
|
||||
|
||||
return common.Executor(func(ctx context.Context) error {
|
||||
if err := info.startContainer()(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
|
||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||
|
||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||
Finally(func(ctx context.Context) error {
|
||||
// Record an interrupt (backstop for interrupts that land outside the main
|
||||
// step loop) so the post steps observe the cancelled/failed job status.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
postCtx, cancel := postStepsContext(ctx)
|
||||
defer cancel()
|
||||
return postExecutor(postCtx)
|
||||
})(ctx)
|
||||
}).Finally(info.closeContainer())
|
||||
}).
|
||||
Finally(info.closeContainer()))
|
||||
}
|
||||
|
||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||
@@ -417,14 +416,6 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
|
||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
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 {
|
||||
|
||||
@@ -300,8 +300,6 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
result string
|
||||
hasError bool
|
||||
output string
|
||||
startError error
|
||||
cancelOnStart bool
|
||||
}{
|
||||
{
|
||||
name: "zeroSteps",
|
||||
@@ -437,21 +435,6 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
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 {
|
||||
@@ -462,8 +445,7 @@ func TestNewJobExecutor(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
|
||||
|
||||
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
defer cancel()
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
@@ -488,12 +470,9 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
jim.On("steps").Return(tt.steps)
|
||||
|
||||
if len(tt.steps) > 0 {
|
||||
jim.On("startContainer").Return(func(_ context.Context) error {
|
||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "startContainer")
|
||||
if tt.cancelOnStart {
|
||||
cancel()
|
||||
}
|
||||
return tt.startError
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -527,7 +506,7 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
defer sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
if len(tt.steps) > 0 && tt.startError == nil {
|
||||
if len(tt.steps) > 0 {
|
||||
jim.On("matrix").Return(map[string]any{})
|
||||
|
||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||
@@ -541,17 +520,12 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
if contains("stopContainer", tt.executedSteps) {
|
||||
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "stopContainer")
|
||||
require.NoError(t, ctx.Err())
|
||||
_, bounded := ctx.Deadline()
|
||||
require.True(t, bounded)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
jim.On("result", tt.result)
|
||||
}
|
||||
|
||||
if len(tt.steps) > 0 {
|
||||
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "closeContainer")
|
||||
return nil
|
||||
@@ -560,14 +534,7 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
|
||||
executor := newJobExecutor(jim, sfm, rc)
|
||||
err := executor(ctx)
|
||||
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.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -35,14 +34,9 @@ func TestMaxParallelStrategy(t *testing.T) {
|
||||
expectedMaxParallel: 4,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-10-clamped-to-combinations",
|
||||
name: "max-parallel-10",
|
||||
maxParallelString: "10",
|
||||
expectedMaxParallel: 5,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-invalid-falls-back-to-default",
|
||||
maxParallelString: "tow",
|
||||
expectedMaxParallel: 4,
|
||||
expectedMaxParallel: 10,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -67,29 +61,9 @@ func TestMaxParallelStrategy(t *testing.T) {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, matrixes)
|
||||
assert.Len(t, matrixes, 5)
|
||||
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
|
||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -107,15 +81,4 @@ func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
||||
runner := &runnerImpl{config: &Config{}}
|
||||
|
||||
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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-24
@@ -28,8 +28,8 @@ import (
|
||||
// of upload-artifact.
|
||||
//
|
||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
|
||||
// client reaches the cache server by its own address rather than through the results origin.
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
@@ -105,45 +105,37 @@ func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
|
||||
func patchActions(ctx context.Context, scripts []string) func() {
|
||||
restore := map[string][]byte{}
|
||||
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's
|
||||
// clone lock, which is what keeps another job's checkout from resetting them before the copy.
|
||||
func patchActions(ctx context.Context, scripts []string) {
|
||||
for _, script := range scripts {
|
||||
original, err := patchBundle(script)
|
||||
if original != nil {
|
||||
restore[script] = original // also when the write failed part way through
|
||||
}
|
||||
if err != nil {
|
||||
switch patched, err := patchBundle(script); {
|
||||
case err != nil:
|
||||
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
case patched:
|
||||
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||
func patchBundle(script string) ([]byte, error) {
|
||||
func patchBundle(script string) (bool, error) {
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return nil, nil
|
||||
return false, nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
return false, nil
|
||||
}
|
||||
return data, os.WriteFile(script, patched, info.Mode().Perm())
|
||||
// No atomic write needed: every prepare checks the action out and hard resets it.
|
||||
return true, os.WriteFile(script, patched, info.Mode().Perm())
|
||||
}
|
||||
|
||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||
|
||||
@@ -176,16 +176,16 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
||||
func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||
script := bundleFile(t, gateTSC)
|
||||
|
||||
original, err := patchBundle(script)
|
||||
done, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, original)
|
||||
require.True(t, done)
|
||||
patched, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(patched)))
|
||||
|
||||
original, err = patchBundle(script)
|
||||
done, err = patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, original, "a patched bundle is not patched again")
|
||||
assert.False(t, done, "a patched bundle is not patched again")
|
||||
again, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(patched), string(again))
|
||||
@@ -194,9 +194,9 @@ func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||
script := bundleFile(t, `console.log("checkout")`)
|
||||
|
||||
original, err := patchBundle(script)
|
||||
done, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, original)
|
||||
assert.False(t, done)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||
@@ -244,16 +244,13 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||
|
||||
var copied string
|
||||
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
|
||||
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
copied = string(body)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
||||
source, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||
return copied
|
||||
}
|
||||
|
||||
|
||||
+188
-164
@@ -16,7 +16,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
maps0 "maps"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -30,6 +29,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/ghcontext"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
@@ -69,7 +69,6 @@ type RunContext struct {
|
||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||
Masks []string
|
||||
cleanUpJobContainer common.Executor
|
||||
deferVolumeCleanup func(common.Executor)
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
|
||||
@@ -99,8 +98,6 @@ type RunContext struct {
|
||||
jobContainerID string
|
||||
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
|
||||
jobNetworkName string
|
||||
dockerProxy *container.DockerProxy
|
||||
hadDockerProxy bool
|
||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||
@@ -256,37 +253,13 @@ func (rc *RunContext) validVolumes() []string {
|
||||
volumes = append(volumes, sharedToolCacheVolume)
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
volumes = append(volumes, rc.dockerProxy.Socket)
|
||||
volumes = append(volumes, rc.Config.Workdir)
|
||||
}
|
||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||
return append(volumes, name, name+"-env",
|
||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||
}
|
||||
|
||||
func (rc *RunContext) workdirMountRoot() string {
|
||||
if rc.Config.PresetGitHubContext != nil {
|
||||
return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is <parent>/<owner>/<repo>
|
||||
}
|
||||
return rc.Config.Workdir
|
||||
}
|
||||
|
||||
func (rc *RunContext) jobDockerSocket() string {
|
||||
if rc.dockerProxy != nil {
|
||||
return rc.dockerProxy.Socket
|
||||
}
|
||||
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
|
||||
}
|
||||
|
||||
func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
||||
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
|
||||
return
|
||||
}
|
||||
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
||||
}
|
||||
|
||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||
func (rc *RunContext) toolCache(fallback string) string {
|
||||
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
|
||||
@@ -356,8 +329,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
||||
// the runner's own mounts below yield to the targets the job claims
|
||||
binds, mounts, claimed := splitVolumes(volumes)
|
||||
|
||||
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||
}
|
||||
if rc.Config.SharedToolCache {
|
||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||
@@ -367,11 +340,6 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||
|
||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||
source := rc.workdirMountRoot()
|
||||
target := ext.ToContainerPath(source)
|
||||
if claimed[target] {
|
||||
source, target = rc.Config.Workdir, workdir
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
bindModifiers := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
@@ -380,9 +348,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
||||
if selinux.GetEnabled() {
|
||||
bindModifiers = ":z"
|
||||
}
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||
} else {
|
||||
mounts[name] = target
|
||||
mounts[name] = workdir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +426,113 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// printStartMacOSVMGroup mirrors the "Starting job container" section for macOS VMs.
|
||||
func printStartMacOSVMGroup(ctx context.Context, image, name string) func() {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger.Infof("::group::Starting job VM")
|
||||
rawLogger.Infof("image: %s", image)
|
||||
rawLogger.Infof("name: %s", name)
|
||||
return func() {
|
||||
rawLogger.Infof("::endgroup::")
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) startMacOSVMEnvironment() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if len(rc.Run.Job().Services) > 0 {
|
||||
return errors.New("service containers are not supported with the macOS VM executor; see docs/macos-vm-executor.md")
|
||||
}
|
||||
|
||||
image := rc.macOSVMImage()
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
name := rc.macOSVMName()
|
||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||
|
||||
guestWorkdir := rc.macOSVMGuestWorkdir()
|
||||
scratch := path.Join("/", rc.macOSVMWorkdirParent(), "scratch", name)
|
||||
actPath := path.Join(scratch, "act")
|
||||
tmpDir := path.Join(scratch, "tmp")
|
||||
toolCache := rc.toolCache(path.Join(scratch, "tool_cache"))
|
||||
|
||||
jobContainer, err := container.NewMacOSVMEnvironment(container.MacOSVMEnvironmentInput{
|
||||
Path: scratch,
|
||||
TmpDir: tmpDir,
|
||||
ToolCache: toolCache,
|
||||
Workdir: rc.Config.Workdir,
|
||||
GuestWorkdir: guestWorkdir,
|
||||
ActPath: actPath,
|
||||
Image: image,
|
||||
VMName: name,
|
||||
ExecutorPath: rc.Config.MacOSVM.ExecutorPath,
|
||||
CPU: rc.Config.MacOSVM.CPU,
|
||||
Memory: rc.Config.MacOSVM.Memory,
|
||||
BootTimeout: rc.Config.MacOSVM.BootTimeout,
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc.JobContainer = jobContainer
|
||||
rc.cleanUpJobContainer = jobContainer.Remove()
|
||||
|
||||
for k, v := range rc.getRunnerContext(ctx) {
|
||||
if v, ok := v.(string); ok {
|
||||
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
for _, env := range os.Environ() {
|
||||
if k, v, ok := strings.Cut(env, "="); ok {
|
||||
if _, ok := rc.Env[k]; !ok {
|
||||
rc.Env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer printStartMacOSVMGroup(ctx, image, name)()
|
||||
return common.NewPipelineExecutor(
|
||||
jobContainer.Pull(rc.Config.ForcePull),
|
||||
jobContainer.Create(nil, nil),
|
||||
jobContainer.Start(false),
|
||||
jobContainer.Exec([]string{"/bin/mkdir", "-p", guestWorkdir, actPath, tmpDir, toolCache}, nil, "", "/"),
|
||||
rc.captureJobContainerInfo(),
|
||||
jobContainer.Copy(jobContainer.GetActPath()+"/", &container.FileEntry{
|
||||
Name: "workflow/event.json",
|
||||
Mode: 0o644,
|
||||
Body: rc.EventJSON,
|
||||
}, &container.FileEntry{
|
||||
Name: "workflow/envs.txt",
|
||||
Mode: 0o666,
|
||||
Body: "",
|
||||
}),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) macOSVMImage() string {
|
||||
return strings.TrimPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
|
||||
}
|
||||
|
||||
func (rc *RunContext) macOSVMName() string {
|
||||
nameParts := []string{"GITEA-MACOS-VM", rc.Config.RunnerUUID, rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
|
||||
if rc.caller != nil {
|
||||
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
||||
}
|
||||
return createContainerName(nameParts...)
|
||||
}
|
||||
|
||||
func (rc *RunContext) macOSVMGuestWorkdir() string {
|
||||
rel := strings.TrimLeft(filepath.ToSlash(rc.Config.Workdir), "/")
|
||||
return path.Join("/", rc.macOSVMWorkdirParent(), rel)
|
||||
}
|
||||
|
||||
func (rc *RunContext) macOSVMWorkdirParent() string {
|
||||
if rc.Config.MacOSVM.WorkdirParent != "" {
|
||||
return rc.Config.MacOSVM.WorkdirParent
|
||||
}
|
||||
return "/Users/admin/runner"
|
||||
}
|
||||
|
||||
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
||||
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
||||
@@ -474,15 +549,8 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
|
||||
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
|
||||
var newContainer = container.NewContainer
|
||||
|
||||
type jobVolumeCleanupKey struct{}
|
||||
|
||||
func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor)) context.Context {
|
||||
return context.WithValue(ctx, jobVolumeCleanupKey{}, deferCleanup)
|
||||
}
|
||||
|
||||
func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
|
||||
logger := common.Logger(ctx)
|
||||
image := rc.platformImage
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
@@ -502,17 +570,19 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
binds, mounts, err := rc.GetBindsAndMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
||||
// if using service containers, will create a new network for the containers.
|
||||
// and it will be removed after at last.
|
||||
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
|
||||
|
||||
services := rc.Run.Job().Services
|
||||
if err := decodeDeferred(ctx, rc.ExprEval, "job services", rc.Run.Job().RawServices, &services); err != nil {
|
||||
return err
|
||||
}
|
||||
for serviceID, spec := range services {
|
||||
// add service containers
|
||||
for serviceID, spec := range rc.Run.Job().Services {
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||
@@ -600,6 +670,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
|
||||
}
|
||||
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||
|
||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||
jobContainerNetwork := networkName
|
||||
|
||||
@@ -607,8 +679,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
containerInput := &container.NewContainerInput{
|
||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||
@@ -617,8 +688,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Password: password,
|
||||
Name: name,
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
NetworkMode: jobContainerNetwork,
|
||||
NetworkAliases: []string{rc.Name},
|
||||
Binds: binds,
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
Privileged: rc.Config.Privileged,
|
||||
@@ -627,34 +700,25 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
RunnerOptions: rc.Config.ContainerOptions,
|
||||
WorkflowOptions: workflowOptions,
|
||||
AutoRemove: true,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
}
|
||||
rc.JobContainer = newContainer(containerInput)
|
||||
})
|
||||
if rc.JobContainer == nil {
|
||||
return errors.New("failed to create job container")
|
||||
}
|
||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
||||
if err := common.NewPipelineExecutor(
|
||||
rc.cleanupJobResources(networkName, createAndDeleteNetwork, true),
|
||||
rc.pullServicesImages(rc.Config.ForcePull),
|
||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||
)(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rc.startDockerProxy(ctx)
|
||||
if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
containerInput.ValidVolumes = rc.validVolumes()
|
||||
|
||||
rc.jobNetworkName = networkName
|
||||
|
||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
||||
return common.NewPipelineExecutor(
|
||||
rc.pullServicesImages(rc.Config.ForcePull),
|
||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||
rc.stopJobContainer(),
|
||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||
IfBool(createAndDeleteNetwork),
|
||||
rc.startServiceContainers(),
|
||||
rc.reportUnstartedServices(),
|
||||
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||
rc.waitForServiceContainers(),
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
rc.captureJobContainerInfo(),
|
||||
@@ -667,7 +731,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Mode: 0o666,
|
||||
Body: "",
|
||||
}),
|
||||
rc.waitForServiceContainers(),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
@@ -680,69 +743,41 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
||||
})
|
||||
}
|
||||
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
|
||||
// cleanupJobResources removes everything the job created, continuing past failures.
|
||||
// Only job container and volume errors are returned, the rest are logged.
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
errs := []error{rc.closeDockerProxy(ctx)}
|
||||
if rc.JobContainer != nil {
|
||||
removeJobContainer := rc.JobContainer != nil
|
||||
|
||||
var errs []error
|
||||
if removeJobContainer {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
}
|
||||
if len(rc.serviceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
errs = append(errs, rc.stopServiceContainers()(ctx))
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
|
||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
||||
}
|
||||
if preclean || rc.deferVolumeCleanup == nil {
|
||||
errs = append(errs, rc.cleanupJobVolumes(ctx))
|
||||
} else {
|
||||
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
||||
if removeJobContainer {
|
||||
// after the containers using them, services can hold these via `--volumes-from`
|
||||
name := rc.jobContainerName()
|
||||
errs = append(errs,
|
||||
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
||||
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// last, once every container has detached
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
|
||||
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
|
||||
if runnerUUID == "" || common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
name := rc.jobContainerName()
|
||||
volumeNames := []string{name, name + "-env"}
|
||||
if _, ok := mounts[name]; !ok { // the workspace is a bind mount, the env volume is always ours
|
||||
volumeNames = volumeNames[1:]
|
||||
}
|
||||
return container.CreateJobVolumes(ctx, runnerUUID, volumeNames)
|
||||
}
|
||||
|
||||
func (rc *RunContext) cleanupJobVolumes(ctx context.Context) error {
|
||||
if rc.JobContainer == nil {
|
||||
return nil
|
||||
}
|
||||
name := rc.jobContainerName()
|
||||
return errors.Join(
|
||||
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
||||
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
||||
}
|
||||
|
||||
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
|
||||
if rc.dockerProxy == nil {
|
||||
return nil
|
||||
}
|
||||
rc.hadDockerProxy = true
|
||||
err := rc.dockerProxy.Close(ctx)
|
||||
rc.dockerProxy = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("close docker proxy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
|
||||
if len(rc.ExtraPath) > 0 {
|
||||
path := rc.JobContainer.GetPathVariableName()
|
||||
@@ -802,6 +837,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
|
||||
func (rc *RunContext) stopJobContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if rc.cleanUpJobContainer != nil {
|
||||
@@ -826,6 +862,7 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
||||
execs := []common.Executor{}
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, common.NewPipelineExecutor(
|
||||
svc.container.Pull(false),
|
||||
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
svc.container.Start(false),
|
||||
))
|
||||
@@ -837,23 +874,19 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
||||
func (rc *RunContext) stopServiceContainers() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
errs := make([]error, len(rc.serviceContainers))
|
||||
for index, svc := range rc.serviceContainers {
|
||||
execs = append(execs, func(ctx context.Context) error {
|
||||
if err := errors.Join(svc.container.Remove()(ctx), svc.container.Close()(ctx)); err != nil {
|
||||
errs[index] = fmt.Errorf("clean service %s: %w", svc.name, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
|
||||
}
|
||||
errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
|
||||
return errors.Join(errs...)
|
||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultServiceReadyTimeout = 5 * time.Minute
|
||||
const (
|
||||
defaultServiceReadyTimeout = 5 * time.Minute
|
||||
serviceReadyPollMax = 32 * time.Second
|
||||
)
|
||||
|
||||
const serviceReadyPollInterval = time.Second
|
||||
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait
|
||||
|
||||
// reportUnstartedServices logs a service that did not start. The steps that need it
|
||||
// report it better than the runner can, so the job carries on.
|
||||
@@ -913,7 +946,7 @@ func (rc *RunContext) waitForServiceContainers() common.Executor {
|
||||
// ready at once and one that exited is left to the steps that need it.
|
||||
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
loggedStarting := false
|
||||
interval := serviceReadyPollInterval
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
@@ -943,14 +976,12 @@ func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !loggedStarting {
|
||||
rawLogger.Infof("%s service is starting.", svc.name)
|
||||
loggedStarting = true
|
||||
}
|
||||
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds()))
|
||||
select {
|
||||
case <-ctx.Done(): // reported at the top of the loop
|
||||
case <-time.After(serviceReadyPollInterval):
|
||||
case <-time.After(interval):
|
||||
}
|
||||
interval = min(interval*2, serviceReadyPollMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,16 +1023,6 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||
return nil
|
||||
}
|
||||
rc.jobContainerID = info.ID
|
||||
if rc.dockerProxy != nil {
|
||||
rc.dockerProxy.SetMounts(info.Mounts)
|
||||
}
|
||||
workspace := rc.githubWorkspace()
|
||||
for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) {
|
||||
if source := info.Mounts[dir]; source != "" {
|
||||
rc.Env["GITEA_DOCKER_WORKSPACE"] = path.Join(source, strings.TrimPrefix(workspace, dir))
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1045,15 +1066,6 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
||||
// resolved values freezing the shared template against later combos.
|
||||
// Resolved up front so one failure publishes none of them, as GitHub does.
|
||||
var deferredOutputs map[string]string
|
||||
if err := decodeDeferred(ctx, ee, "job outputs", job.RawOutputs, &deferredOutputs); err != nil {
|
||||
return err
|
||||
}
|
||||
if deferredOutputs != nil {
|
||||
defer lockJob(job)()
|
||||
job.Outputs = deferredOutputs
|
||||
return nil
|
||||
}
|
||||
outputs := make(map[string]string, len(rc.outputTemplate))
|
||||
var err error
|
||||
for k, v := range rc.outputTemplate {
|
||||
@@ -1077,9 +1089,12 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
func (rc *RunContext) startContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
var err error
|
||||
if rc.IsHostEnv() {
|
||||
switch {
|
||||
case rc.IsHostEnv():
|
||||
err = rc.startHostEnvironment()(ctx)
|
||||
} else {
|
||||
case rc.IsMacOSVMEnv():
|
||||
err = rc.startMacOSVMEnvironment()(ctx)
|
||||
default:
|
||||
err = rc.startJobContainer()(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1092,13 +1107,17 @@ func (rc *RunContext) startContainer() common.Executor {
|
||||
}
|
||||
|
||||
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
||||
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
cleanup := rc.cleanUpJobContainer
|
||||
if cleanup == nil {
|
||||
cleanup = rc.closeDockerProxy
|
||||
if rc.cleanUpJobContainer == nil {
|
||||
return
|
||||
}
|
||||
if err := cleanup(cleanCtx); err != nil {
|
||||
cleanCtx := ctx
|
||||
if ctx.Err() != nil {
|
||||
// the start likely failed because ctx was cancelled, detach so teardown still runs
|
||||
var cancel context.CancelFunc
|
||||
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||
defer cancel()
|
||||
}
|
||||
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
|
||||
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
|
||||
}
|
||||
}
|
||||
@@ -1107,6 +1126,11 @@ func (rc *RunContext) IsHostEnv() bool {
|
||||
return strings.EqualFold(rc.platformImage, "-self-hosted")
|
||||
}
|
||||
|
||||
// IsMacOSVMEnv reports whether the resolved platform image requests a macOS VM.
|
||||
func (rc *RunContext) IsMacOSVMEnv() bool {
|
||||
return strings.HasPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
|
||||
}
|
||||
|
||||
func (rc *RunContext) stopContainer() common.Executor {
|
||||
return rc.stopJobContainer()
|
||||
}
|
||||
@@ -1710,7 +1734,7 @@ func imageOSFromImage(image string) string {
|
||||
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
|
||||
if actionsRuntimeURL == "" {
|
||||
actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/"
|
||||
actionsRuntimeURL = fmt.Sprintf("http://%s:%s/", rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)
|
||||
}
|
||||
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
|
||||
|
||||
|
||||
+105
-361
@@ -7,30 +7,22 @@ package runner
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/moby/moby/api/types/volume"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -230,9 +222,8 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
|
||||
|
||||
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
||||
|
||||
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||
func (fakeContainer) ToContainerPath(path string) string { return path }
|
||||
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||
func (fakeContainer) Create([]string, []string) common.Executor {
|
||||
return func(context.Context) error { return nil }
|
||||
}
|
||||
@@ -247,20 +238,8 @@ func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
|
||||
|
||||
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
||||
|
||||
func fakeDockerDaemon(t *testing.T, handler http.HandlerFunc) {
|
||||
t.Helper()
|
||||
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(request.URL.Path, "/_ping") {
|
||||
writer.Header().Set("API-Version", "1.47")
|
||||
} else {
|
||||
handler(writer, request)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(daemon.Close)
|
||||
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||
}
|
||||
|
||||
// startJobContainerInputs runs startJobContainer against fakeContainer and returns the
|
||||
// inputs it built, one per container.
|
||||
func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput {
|
||||
t.Helper()
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML))
|
||||
@@ -291,28 +270,9 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
|
||||
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/_ping"):
|
||||
w.Header().Set("API-Version", "1.47")
|
||||
_, _ = io.WriteString(w, "OK")
|
||||
case strings.HasSuffix(r.URL.Path, "/containers/json"), strings.HasSuffix(r.URL.Path, "/networks"):
|
||||
_, _ = io.WriteString(w, "[]")
|
||||
case strings.HasSuffix(r.URL.Path, "/volumes"):
|
||||
_, _ = io.WriteString(w, `{"Volumes":[]}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/volumes/"):
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
|
||||
default:
|
||||
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(daemon.Close)
|
||||
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||
// the inputs are built before the missing daemon fails the first call
|
||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||
require.Error(t, rc.startJobContainer()(t.Context()))
|
||||
|
||||
return inputs
|
||||
}
|
||||
@@ -372,19 +332,6 @@ jobs:
|
||||
require.Empty(t, redis.WorkingDir)
|
||||
}
|
||||
|
||||
// A whole-value `services:` expression only reaches the typed field through DecodeRaw.
|
||||
func TestStartJobContainerGivesServicesTheirVolumesFromExpression(t *testing.T) {
|
||||
redis := startJobContainerInputs(t, `
|
||||
jobs:
|
||||
job:
|
||||
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"]}}') }}
|
||||
`, &Config{ValidVolumes: []string{"data"}})[0]
|
||||
|
||||
require.Equal(t, "redis:latest", redis.Image)
|
||||
require.Equal(t, []string{"data"}, redis.ValidVolumes)
|
||||
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
||||
}
|
||||
|
||||
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
||||
// container apart from each other.
|
||||
func TestStartJobContainerKeepsRunnerOptionsApartFromWorkflowOptions(t *testing.T) {
|
||||
@@ -641,30 +588,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||
})
|
||||
|
||||
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("container paths are linux paths")
|
||||
}
|
||||
rc := &RunContext{
|
||||
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}},
|
||||
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}},
|
||||
}
|
||||
|
||||
gotbind, _, err := rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
||||
|
||||
rc.Config.BindWorkdir = false
|
||||
_, gotmount, err := rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||
|
||||
require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}}))
|
||||
_, gotmount, err = rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunContextValidVolumes(t *testing.T) {
|
||||
@@ -693,8 +616,6 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||
rc.Config.BindWorkdir = true
|
||||
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
||||
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
@@ -707,142 +628,34 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
}
|
||||
|
||||
err := rc.cleanupJobResources("external-network", false, true)(common.WithDryrun(t.Context(), true))
|
||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
||||
require.NoError(t, err)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
||||
now := time.Date(2026, time.September, 10, 12, 0, 0, 0, time.UTC)
|
||||
rc := &RunContext{
|
||||
Config: &Config{ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{RunnerUUID: "runner-1"}},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
|
||||
JobContainer: fakeContainer{},
|
||||
}
|
||||
volumes := map[string]volume.Volume{}
|
||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||
switch {
|
||||
case path == "/volumes/create":
|
||||
options := volume.Volume{CreatedAt: now.Format(time.RFC3339)}
|
||||
assert.NoError(t, json.UnmarshalRead(request.Body, &options))
|
||||
volumes[options.Name] = options
|
||||
assert.NoError(t, json.MarshalWrite(writer, volumes[options.Name]))
|
||||
case path == "/volumes":
|
||||
assert.JSONEq(t, `{"label":{"com.gitea.runner.uuid=runner-1":true},"dangling":{"true":true}}`, request.URL.Query().Get("filters"))
|
||||
assert.NoError(t, json.MarshalWrite(writer, map[string]any{"Volumes": slices.Collect(maps.Values(volumes))}))
|
||||
case request.Method == http.MethodDelete:
|
||||
name := strings.TrimPrefix(path, "/volumes/")
|
||||
assert.NotContains(t, []string{"1", "true"}, request.URL.Query().Get("force"))
|
||||
if name == "became-active" || name == "remove-failed" {
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
_, _ = fmt.Fprintf(writer, `{"message":%q}`, name)
|
||||
return
|
||||
}
|
||||
delete(volumes, name)
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
|
||||
}
|
||||
})
|
||||
name := rc.jobContainerName()
|
||||
require.NoError(t, rc.createJobVolumes(t.Context(), map[string]string{name: "/workspace", name + "-env": "/var/run/act", "shared-cache": "/cache"}))
|
||||
assert.ElementsMatch(t, []string{name, name + "-env"}, slices.Collect(maps.Keys(volumes)))
|
||||
rc.deferVolumeCleanup = func(common.Executor) {}
|
||||
require.NoError(t, rc.cleanupJobResources("", false, false)(t.Context()))
|
||||
require.Len(t, volumes, 2)
|
||||
for _, name := range []string{"became-active", "remove-failed"} {
|
||||
volumes[name] = volume.Volume{Name: name, Labels: volumes[rc.jobContainerName()].Labels, CreatedAt: now.Format(time.RFC3339)}
|
||||
}
|
||||
volumes["foreign"] = volume.Volume{Name: "foreign", Labels: map[string]string{"com.gitea.runner.uuid": "runner-2"}, CreatedAt: now.Format(time.RFC3339)}
|
||||
volumes["fresh"] = volume.Volume{Name: "fresh", Labels: volumes[name].Labels, CreatedAt: now.Add(48 * time.Hour).Format(time.RFC3339)}
|
||||
volumes["unknown-age"] = volume.Volume{Name: "unknown-age", Labels: volumes[name].Labels}
|
||||
err := container.RemoveOrphanJobVolumes(t.Context(), "runner-1", now.Add(24*time.Hour))
|
||||
require.ErrorContains(t, err, "became-active")
|
||||
require.ErrorContains(t, err, "remove-failed")
|
||||
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||
}
|
||||
|
||||
// cleanup used to bail out on a previous step's error and on a cancelled context
|
||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
t.Setenv("TMPDIR", "/tmp")
|
||||
proxyDir := t.TempDir()
|
||||
for _, name := range []string{"synchronous", "proxy", "closed proxy", "deferred", "preclean deferred", "canceled"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
||||
if proxy && runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||
}
|
||||
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
|
||||
job, service := &containerMock{}, &containerMock{}
|
||||
job.On("Remove").Return(func(context.Context) error { return jobError }).Once()
|
||||
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: job,
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
}
|
||||
var volumeCleanup []common.Executor
|
||||
if deferred {
|
||||
rc.deferVolumeCleanup = func(cleanup common.Executor) { volumeCleanup = append(volumeCleanup, cleanup) }
|
||||
}
|
||||
volumeRemovals := 0
|
||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodDelete {
|
||||
volumeRemovals++
|
||||
}
|
||||
operation := request.Method + " " + strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||
if request.URL.Query().Has("filters") {
|
||||
operation = "labelled " + operation
|
||||
}
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprintf(writer, `{"message":%q}`, operation)
|
||||
})
|
||||
if proxy {
|
||||
listener, err := net.Listen("unix", filepath.Join(proxyDir, "d.sock"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, listener.Close()) })
|
||||
rc.dockerProxy, err = container.StartDockerProxy(listener.Addr().String(), proxyDir, rc.jobContainerName())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, rc.closeDockerProxy(context.Background())) })
|
||||
if name == "closed proxy" {
|
||||
require.NoError(t, rc.closeDockerProxy(t.Context()))
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
if name == "canceled" {
|
||||
cancel()
|
||||
}
|
||||
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
|
||||
if deferred && !preclean {
|
||||
require.Len(t, volumeCleanup, 1)
|
||||
assert.Zero(t, volumeRemovals)
|
||||
err = errors.Join(err, volumeCleanup[0](ctx))
|
||||
} else {
|
||||
assert.Empty(t, volumeCleanup)
|
||||
}
|
||||
for _, failure := range []error{jobError, removeError, closeError} {
|
||||
require.ErrorIs(t, err, failure)
|
||||
}
|
||||
if name == "canceled" {
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
} else {
|
||||
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "GET /networks"} {
|
||||
require.ErrorContains(t, err, operation)
|
||||
}
|
||||
assert.Equal(t, 2, volumeRemovals)
|
||||
}
|
||||
assert.Equal(t, proxy || preclean, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||
if proxy || preclean {
|
||||
require.ErrorContains(t, err, "labelled GET /networks")
|
||||
require.ErrorContains(t, err, "labelled GET /volumes")
|
||||
}
|
||||
assert.Nil(t, rc.dockerProxy)
|
||||
assert.Equal(t, proxy, rc.hadDockerProxy)
|
||||
})
|
||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||
|
||||
jobContainer := &containerMock{}
|
||||
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
|
||||
service := &containerMock{}
|
||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: jobContainer,
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
|
||||
jobContainer.AssertExpectations(t)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||
@@ -866,20 +679,6 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
||||
require.Equal(t, "b", job.Outputs["o"])
|
||||
}
|
||||
|
||||
// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw.
|
||||
func TestInterpolateOutputsFromExpression(t *testing.T) {
|
||||
var rawOutputs yaml.Node
|
||||
require.NoError(t, rawOutputs.Encode(`${{ fromJSON('{"o":"resolved"}') }}`))
|
||||
|
||||
job := &model.Job{RawOutputs: rawOutputs}
|
||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
||||
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), run, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, rc.interpolateOutputs()(t.Context()))
|
||||
require.Equal(t, "resolved", job.Outputs["o"])
|
||||
}
|
||||
|
||||
func TestGetGitHubContext(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
@@ -1266,96 +1065,57 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
type ctxKey string
|
||||
const sentinel = ctxKey("sentinel")
|
||||
|
||||
for name, canceled := range map[string]bool{"cancellation during cleanup": false, "already canceled": true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.WithValue(t.Context(), sentinel, "v"))
|
||||
defer cancel()
|
||||
if canceled {
|
||||
cancel()
|
||||
}
|
||||
calls := 0
|
||||
(&RunContext{cleanUpJobContainer: func(ctx context.Context) error {
|
||||
calls++
|
||||
cancel()
|
||||
deadline, ok := ctx.Deadline()
|
||||
require.True(t, ok)
|
||||
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
|
||||
require.NoError(t, ctx.Err())
|
||||
assert.Equal(t, "v", ctx.Value(sentinel))
|
||||
// the fresh context is cancelled via defer on return, so capture state inside the stub
|
||||
type capture struct {
|
||||
calls int
|
||||
err error
|
||||
sentinel any
|
||||
}
|
||||
newRC := func(c *capture) *RunContext {
|
||||
return &RunContext{
|
||||
JobName: "job",
|
||||
cleanUpJobContainer: func(ctx context.Context) error {
|
||||
c.calls++
|
||||
c.err = ctx.Err()
|
||||
c.sentinel = ctx.Value(sentinel)
|
||||
return nil
|
||||
}}).cleanupFailedStart(ctx)
|
||||
assert.Equal(t, 1, calls)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for _, testcase := range []struct {
|
||||
name, health string
|
||||
pullError error
|
||||
}{
|
||||
{name: "healthy", health: container.HealthHealthy},
|
||||
{name: "unhealthy", health: container.HealthUnhealthy},
|
||||
{name: "pull failure", pullError: errors.New("pull failed")},
|
||||
} {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
var operations []string
|
||||
record := func(operation string, failure error) func(context.Context) error {
|
||||
return func(context.Context) error {
|
||||
operations = append(operations, operation)
|
||||
return failure
|
||||
}
|
||||
}
|
||||
job, service := &containerMock{}, &containerMock{}
|
||||
for name, instance := range map[string]*containerMock{"job": job, "service": service} {
|
||||
instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name]))
|
||||
instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
|
||||
instance.On("Start", false).Return(record(name+".Start", nil))
|
||||
instance.On("Remove").Return(record(name+".Remove", nil))
|
||||
instance.On("Close").Return(record(name+".Close", nil))
|
||||
}
|
||||
job.On("Copy", mock.Anything, mock.Anything).Return(record("job.Copy", nil))
|
||||
job.On("Inspect", mock.Anything).Return(&container.Info{ID: "job-id"}, nil).
|
||||
Run(func(mock.Arguments) { operations = append(operations, "job.Inspect") })
|
||||
for _, health := range []string{container.HealthStarting, testcase.health} {
|
||||
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
|
||||
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
|
||||
}
|
||||
service.On("DumpLogs", mock.Anything).Return(nil).
|
||||
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") })
|
||||
origNewContainer := newContainer
|
||||
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
|
||||
}
|
||||
t.Cleanup(func() { newContainer = origNewContainer })
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader("jobs: {job: {services: {postgres: {image: postgres:latest}}}}"))
|
||||
require.NoError(t, err)
|
||||
rc := &RunContext{
|
||||
Config: &Config{ForcePull: true, ContainerNetworkMode: "host", Workdir: "/workspace"},
|
||||
Run: &model.Run{JobID: "job", Workflow: workflow},
|
||||
platformImage: "node:20",
|
||||
}
|
||||
ctx := common.WithDryrun(t.Context(), true)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
|
||||
want := "job.Remove service.Remove service.Close service.Pull job.Pull"
|
||||
if testcase.pullError != nil {
|
||||
require.ErrorIs(t, err, testcase.pullError)
|
||||
} else {
|
||||
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect"
|
||||
if testcase.health == container.HealthUnhealthy {
|
||||
require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
|
||||
want += " service.DumpLogs"
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
|
||||
})
|
||||
}
|
||||
t.Run("runs teardown on the live context", func(t *testing.T) {
|
||||
var c capture
|
||||
ctx := context.WithValue(context.Background(), sentinel, "v")
|
||||
|
||||
(&RunContext{}).cleanupFailedStart(t.Context())
|
||||
newRC(&c).cleanupFailedStart(ctx)
|
||||
|
||||
assert.Equal(t, 1, c.calls)
|
||||
require.NoError(t, c.err)
|
||||
assert.Equal(t, "v", c.sentinel)
|
||||
})
|
||||
|
||||
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
|
||||
var c capture
|
||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
|
||||
cancel()
|
||||
|
||||
newRC(&c).cleanupFailedStart(ctx)
|
||||
|
||||
assert.Equal(t, 1, c.calls)
|
||||
require.NoError(t, c.err)
|
||||
assert.Nil(t, c.sentinel)
|
||||
})
|
||||
|
||||
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
|
||||
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestWaitForServiceContainers(t *testing.T) {
|
||||
origInterval := serviceReadyPollInterval
|
||||
serviceReadyPollInterval = time.Millisecond
|
||||
defer func() { serviceReadyPollInterval = origInterval }()
|
||||
|
||||
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
|
||||
return &RunContext{
|
||||
Config: &Config{ServiceReadyTimeout: timeout},
|
||||
@@ -1373,26 +1133,16 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
service.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("polls at a fixed interval and logs starting only once", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var pollTimes []time.Duration
|
||||
started := time.Now()
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
||||
service.On("Inspect", mock.Anything).
|
||||
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||
t.Run("waits while a service is still starting", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||
|
||||
var output bytes.Buffer
|
||||
logger := log.New()
|
||||
logger.SetOutput(&output)
|
||||
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger.WithFields(nil))))
|
||||
assert.Equal(t, []time.Duration{0, time.Second, 2 * time.Second}, pollTimes)
|
||||
assert.Equal(t, 1, strings.Count(output.String(), "postgres service is starting."))
|
||||
assert.Contains(t, output.String(), "postgres service is healthy.")
|
||||
})
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
service.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
|
||||
@@ -1545,29 +1295,6 @@ func TestGetJobContextReportsContainers(t *testing.T) {
|
||||
}, jobContext.Services)
|
||||
}
|
||||
|
||||
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
||||
job := &containerMock{}
|
||||
job.On("Inspect", mock.Anything).Return(&container.Info{
|
||||
ID: "job-container-id",
|
||||
Mounts: map[string]string{"/workspace": "/var/lib/docker/volumes/job/_data"},
|
||||
}, nil)
|
||||
rc := &RunContext{
|
||||
Config: &Config{Workdir: "/workspace/owner/repo/"},
|
||||
Env: map[string]string{},
|
||||
JobContainer: job,
|
||||
}
|
||||
|
||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
||||
|
||||
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
||||
assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
||||
|
||||
rc.Config.Workdir = "/elsewhere"
|
||||
rc.Env = map[string]string{}
|
||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
||||
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
|
||||
}
|
||||
|
||||
// A job that never started a container reports an empty context, not a placeholder.
|
||||
func TestGetJobContextWithoutContainer(t *testing.T) {
|
||||
jobContext := (&RunContext{}).getJobContext()
|
||||
@@ -1723,10 +1450,6 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.RunnerName = "runner-1"
|
||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||
t.Setenv("ACTIONS_RUNTIME_URL", "")
|
||||
rc.Config.ArtifactServerPath = "artifacts"
|
||||
rc.Config.ArtifactServerAddr = "2001:db8::1"
|
||||
rc.Config.ArtifactServerPort = "8080"
|
||||
|
||||
env := map[string]string{}
|
||||
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
|
||||
@@ -1735,5 +1458,26 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
||||
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
|
||||
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
||||
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
||||
assert.Equal(t, "http://[2001:db8::1]:8080/", env["ACTIONS_RUNTIME_URL"])
|
||||
}
|
||||
|
||||
func TestRunContextMacOSVMPlatform(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "macos-latest")
|
||||
rc.Config.PlatformPicker = func([]string) string {
|
||||
return labels.MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||
}
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
|
||||
assert.True(t, rc.IsMacOSVMEnv())
|
||||
assert.False(t, rc.IsHostEnv())
|
||||
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", rc.macOSVMImage())
|
||||
}
|
||||
|
||||
func TestRunContextMacOSVMGuestWorkdir(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "macos-latest")
|
||||
rc.Config.Workdir = "/workspace/owner/repo"
|
||||
rc.Config.MacOSVM.WorkdirParent = "/Users/admin/runner"
|
||||
rc.Config.RunnerUUID = "runner-1"
|
||||
|
||||
assert.Equal(t, "/Users/admin/runner/workspace/owner/repo", rc.macOSVMGuestWorkdir())
|
||||
assert.Contains(t, rc.macOSVMName(), "GITEA-MACOS-VM-runner-1-")
|
||||
}
|
||||
+36
-28
@@ -19,7 +19,6 @@ import (
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// Config contains the config for a new runner
|
||||
@@ -80,9 +79,20 @@ type Config struct {
|
||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||
RunnerUUID string // UUID this runner registered with, used to identify its macOS VMs
|
||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||
MacOSVM MacOSVMConfig // configures the macOS VM executor
|
||||
}
|
||||
|
||||
// MacOSVMConfig contains the macOS VM executor settings that come from the runner config.
|
||||
type MacOSVMConfig struct {
|
||||
ExecutorPath string // path to the runner-executor-macos binary
|
||||
WorkdirParent string // guest directory under which the job workspace is created
|
||||
CPU int // number of virtual CPUs, 0 keeps the image default
|
||||
Memory int // VM memory in megabytes, 0 keeps the image default
|
||||
BootTimeout time.Duration // how long to wait for the VM to become ready
|
||||
}
|
||||
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||
@@ -156,18 +166,6 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
|
||||
return runner, nil
|
||||
}
|
||||
|
||||
func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
||||
if strategy != nil {
|
||||
if limit, declared, err := strategy.ParseMaxParallel(); err != nil {
|
||||
log.Errorf("Ignoring invalid max-parallel: %v", err)
|
||||
} else if declared {
|
||||
maxParallel = limit
|
||||
}
|
||||
}
|
||||
return min(maxParallel, combinations)
|
||||
}
|
||||
|
||||
// NewPlanExecutor ...
|
||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
maxJobNameLen := 0
|
||||
@@ -204,24 +202,21 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
log.Debugf("Job.With: %v", job.With)
|
||||
log.Debugf("Job.Result: %v", job.Result)
|
||||
|
||||
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||
if job.Strategy != nil {
|
||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.FailFast)
|
||||
log.Debugf("Job.Strategy.MaxParallel: %v", job.Strategy.MaxParallel)
|
||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
|
||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job.RawStrategy.Kind == yaml.ScalarNode {
|
||||
if err := decodeDeferred(ctx, strategyRc.ExprEval, "job strategy", job.RawStrategy, &job.Strategy); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||
log.Errorf("Error while evaluating matrix: %v", err)
|
||||
}
|
||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||
log.Errorf("Error while evaluating matrix: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +226,20 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
}
|
||||
log.Debugf("Job Matrices: %v", matrixes)
|
||||
|
||||
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
||||
maxParallel := 4
|
||||
if job.Strategy != nil {
|
||||
// Ensure GetMaxParallel() is called if MaxParallel is still 0
|
||||
if job.Strategy.MaxParallel == 0 {
|
||||
job.Strategy.MaxParallel = job.Strategy.GetMaxParallel()
|
||||
}
|
||||
maxParallel = job.Strategy.MaxParallel
|
||||
log.Debugf("Using job.Strategy.MaxParallel: %d", maxParallel)
|
||||
}
|
||||
|
||||
if len(matrixes) < maxParallel {
|
||||
log.Debugf("Adjusting maxParallel from %d to %d (number of matrix combinations)", maxParallel, len(matrixes))
|
||||
maxParallel = len(matrixes)
|
||||
}
|
||||
|
||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type step interface {
|
||||
@@ -262,14 +261,6 @@ func setupEnv(ctx context.Context, step step) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if step.getStepModel().RawWith.Kind == yaml.ScalarNode {
|
||||
decoded := &model.Step{}
|
||||
if err := decodeDeferred(ctx, inputEval(), "with", step.getStepModel().RawWith, &decoded.With); err != nil {
|
||||
return err
|
||||
}
|
||||
step.getStepModel().With = decoded.With
|
||||
mergeIntoMap(step, step.getEnv(), decoded.GetEnv())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return fmt.Errorf("unable to interpolate with.path: %w", err)
|
||||
}
|
||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -206,55 +205,6 @@ func TestSetupEnv(t *testing.T) {
|
||||
}, env)
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
|
||||
for _, expression := range []string{"inputs.args", "env.ARGS"} {
|
||||
t.Run("deferred inputs from "+expression, func(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
env: ${{ fromJSON(matrix.env) }}
|
||||
jobs:
|
||||
test:
|
||||
env:
|
||||
PRIORITY: job
|
||||
steps:
|
||||
- uses: ./act
|
||||
env:
|
||||
ARGS: ${{ inputs.args }}
|
||||
with: ${{ fromJSON(` + expression + `) }}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
job := workflow.GetJob("test")
|
||||
rawEnv := model.CloneYamlNode(workflow.RawEnv)
|
||||
rawWith := model.CloneYamlNode(job.Steps[0].RawWith)
|
||||
for _, value := range []string{"first", "second"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `","PRIORITY":"workflow"}`})
|
||||
require.NoError(t, err)
|
||||
rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `","fetch-depth":2}`}
|
||||
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||
step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}}
|
||||
require.NoError(t, setupEnv(t.Context(), step))
|
||||
assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value, "INPUT_FETCH-DEPTH": "2"}, step.Step.GetEnv())
|
||||
assert.Equal(t, value, step.env["INPUT_NAME"])
|
||||
assert.Equal(t, value, step.env["WORKFLOW"])
|
||||
assert.Equal(t, "job", step.env["PRIORITY"])
|
||||
assert.Equal(t, rawEnv, workflow.RawEnv)
|
||||
assert.Equal(t, rawWith, job.Steps[0].RawWith)
|
||||
assert.Nil(t, workflow.Env)
|
||||
assert.Nil(t, job.Steps[0].With)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("rejects inputs that still contain an expression", func(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n steps:\n - uses: ./act\n with: ${{ inputs.args }}\n"))
|
||||
require.NoError(t, err)
|
||||
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||
require.NoError(t, err)
|
||||
rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"}
|
||||
require.ErrorContains(t, setupEnv(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsStepEnabled(t *testing.T) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
name: docker-proxy
|
||||
description: Verify mounted Docker access through post steps
|
||||
runs:
|
||||
using: node24
|
||||
main: index.js
|
||||
post: index.js
|
||||
@@ -1,53 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const {once} = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
|
||||
async function request(method, path, body) {
|
||||
const req = http.request({
|
||||
socketPath: '/var/run/docker.sock',
|
||||
method,
|
||||
path,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
req.end(JSON.stringify(body));
|
||||
const [res] = await once(req, 'response');
|
||||
req.on('error', (error) => res.destroy(error));
|
||||
let data = '';
|
||||
for await (const chunk of res.setEncoding('utf8')) {
|
||||
data += chunk;
|
||||
}
|
||||
assert(res.statusCode >= 200 && res.statusCode < 300, `${method} ${path}: ${res.statusCode} ${data}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(fs.statSync('/var/run/docker.sock').isSocket(), 'Docker mount must be a socket');
|
||||
assert.equal(await request('GET', '/_ping'), 'OK');
|
||||
const api = `/v${JSON.parse(await request('GET', '/version')).ApiVersion}`;
|
||||
const name = process.env.PROXY_TEST_RESOURCE;
|
||||
const job = process.env.JOB_CONTAINER_NAME;
|
||||
const post = process.env.STATE_post === 'true';
|
||||
assert(name);
|
||||
assert(job);
|
||||
if (!post) {
|
||||
await request('POST', `${api}/volumes/create`, {Name: name});
|
||||
await request('POST', `${api}/networks/create`, {Name: name});
|
||||
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
|
||||
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
|
||||
}
|
||||
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
|
||||
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
|
||||
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
|
||||
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
|
||||
assert(Object.values(network.Containers).some((container) => container.Name === job));
|
||||
if (post) {
|
||||
console.log('docker proxy post verified');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
name: docker-proxy
|
||||
on: push
|
||||
jobs:
|
||||
proxy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./action
|
||||
- if: env.PROXY_TEST_MODE == 'proxy'
|
||||
run: |
|
||||
docker run -d --restart always --network "$PROXY_TEST_RESOURCE" --name "$PROXY_TEST_RESOURCE-detached" "$PROXY_TEST_IMAGE" sleep infinity
|
||||
echo "{services: {app: {image: $PROXY_TEST_IMAGE, command: sleep infinity, volumes: [named:/named]}}, volumes: {named: {}}}" | docker compose -f - up -d
|
||||
binds:
|
||||
needs: proxy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
test -z "$(docker ps -aq -f name="$PROXY_TEST_RESOURCE-detached" -f name="$PROXY_TEST_RESOURCE-app"; docker volume ls -q -f name="${PROXY_TEST_RESOURCE}_named"; docker network ls -q -f name="${PROXY_TEST_RESOURCE}_default")"
|
||||
mkdir data && echo from-job > data/marker
|
||||
- id: run
|
||||
run: |
|
||||
docker run --rm -v "${{ github.workspace }}:/src:ro" -v "$PWD:$PWD" -v ./data/created:/created -v "$GITHUB_OUTPUT:/output" \
|
||||
--mount "type=bind,src=$PWD/data/../data,dst=/spelled,readonly" \
|
||||
--mount "type=volume,dst=/device,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$PWD/data" \
|
||||
"$PROXY_TEST_IMAGE" sh -c 'cat /src/data/marker "$0/data/marker" /spelled/marker /device/marker | grep -c from-job | grep -qx 4 && echo from-nested > /created/file && echo value=from-nested >> /output' "$PWD"
|
||||
grep -qx from-nested data/created/file
|
||||
- run: |
|
||||
test "${{ steps.run.outputs.value }}" = from-nested
|
||||
cat > compose.yaml <<'EOF'
|
||||
services:
|
||||
app:
|
||||
image: ${PROXY_TEST_IMAGE}
|
||||
command: cat /short/marker /long/marker /named/marker
|
||||
volumes:
|
||||
- ./data:/short
|
||||
- type: bind
|
||||
source: ./data
|
||||
target: /long
|
||||
bind:
|
||||
create_host_path: false
|
||||
- named:/named
|
||||
volumes:
|
||||
named:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./data
|
||||
EOF
|
||||
test "$(docker compose run --rm app | grep -c from-job)" = 3
|
||||
docker compose down --volumes
|
||||
- run: |
|
||||
docker run --rm --user 1001 --group-add 2375 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/local/bin/docker:/usr/local/bin/docker:ro -v "${GITEA_DOCKER_WORKSPACE:?}/data:/data:ro" \
|
||||
"$PROXY_TEST_IMAGE" sh -c 'grep -qx from-job /data/marker && docker volume create "$0"' "$PROXY_TEST_RESOURCE-nested"
|
||||
test "$(docker volume inspect -f '{{ index .Labels "com.gitea.runner.job" }}' "$PROXY_TEST_RESOURCE-nested")" = "$JOB_CONTAINER_NAME"
|
||||
echo "docker binds verified"
|
||||
@@ -0,0 +1,93 @@
|
||||
# macOS VM executor
|
||||
|
||||
The runner can execute a job in an ephemeral macOS VM by registering a
|
||||
`macos-vm` label:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
labels:
|
||||
- macos-latest:macos-vm://registry.example.com/macos-15.2-xcode-26.1
|
||||
```
|
||||
|
||||
The VM is created and managed by `runner-executor-macos`, a dedicated Swift
|
||||
binary built on Apple Virtualization.framework. The source VM must include the
|
||||
runner guest agent.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
macos_vm:
|
||||
executor_path: runner-executor-macos
|
||||
workdir_parent: /Users/admin/runner
|
||||
cpu: 0
|
||||
memory: 0
|
||||
boot_timeout: 5m
|
||||
```
|
||||
|
||||
`workdir_parent` is the guest directory under which job workspaces are created.
|
||||
`executor_path` is the binary to invoke; an empty value uses PATH lookup.
|
||||
|
||||
## Executor CLI
|
||||
|
||||
The runner uses a narrow, runner-oriented command set:
|
||||
|
||||
```text
|
||||
runner-executor-macos provision <image> <name> [--cpu N] [--memory MB]
|
||||
runner-executor-macos start <name> [--no-graphics]
|
||||
runner-executor-macos exec <name> [--stdin] <argv...>
|
||||
runner-executor-macos stop <name> [--timeout N]
|
||||
runner-executor-macos destroy <name>
|
||||
runner-executor-macos list --format json
|
||||
runner-executor-macos version
|
||||
```
|
||||
|
||||
`list --format json` returns:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "GITEA-MACOS-VM-<runner-uuid>-...",
|
||||
"state": "stopped",
|
||||
"running": false,
|
||||
"accessed_at": "2026-09-05T12:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Integration test
|
||||
|
||||
The end-to-end test is opt-in because it provisions and boots a real VM:
|
||||
|
||||
```bash
|
||||
MACOS_VM_TEST_IMAGE=/path/or/reference \
|
||||
go test -tags macos_vm_integration ./act/container -run '^TestMacOSVMEnvironmentIntegration$' -v
|
||||
```
|
||||
|
||||
The test skips when `MACOS_VM_TEST_IMAGE` is unset.
|
||||
|
||||
## Orphaned VM cleanup
|
||||
|
||||
Job VM names start with `GITEA-MACOS-VM-<runner-uuid>-`. While the runner is
|
||||
idle, it lists local VMs and destroys the non-running ones whose name has this
|
||||
runner's UUID and whose access time is older than
|
||||
`runner.workdir_cleanup_age`.
|
||||
|
||||
## Service containers
|
||||
|
||||
Service containers are intentionally rejected for macOS VM jobs today. Docker
|
||||
service containers live on a Docker network and are reached by workflow service
|
||||
id, which a macOS VM cannot resolve.
|
||||
|
||||
Possible future designs:
|
||||
|
||||
1. **Host-published Docker services + guest host mapping** — publish each
|
||||
service port on the runner host and inject `<service-id> -> <host-ip>` into
|
||||
the VM through the guest agent.
|
||||
2. **macOS VM services** — run each service as another VM; this requires
|
||||
service discovery and is much heavier than Docker containers.
|
||||
3. **Environment-based discovery** — require workflows to read service
|
||||
addresses from env vars instead of hostnames, breaking GitHub Actions
|
||||
compatibility.
|
||||
|
||||
Option 1 is the most compatible direction once the guest agent and host network
|
||||
configuration are stable.
|
||||
@@ -28,24 +28,3 @@ func testActionsCacheRoundTrip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testArtifactRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v2 := true
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
options runnerOptions
|
||||
}{
|
||||
{"through_the_cache_server", runnerOptions{cacheV2: &v2}},
|
||||
{"direct_with_no_reachable_cache", runnerOptions{cacheHost: "cache.invalid", cacheV2: new(bool)}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
api, repo, _ := startIsolatedScenario(t, "artifact.yml", "e2e-artifact", tc.options)
|
||||
|
||||
wfRun := waitForRun(t, api, repo)
|
||||
requireSuccess(t, api, repo, wfRun.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ func TestCompatibility(t *testing.T) {
|
||||
|
||||
t.Run("payloads", testPayloads)
|
||||
t.Run("cache", testActionsCacheRoundTrip)
|
||||
t.Run("artifact", testArtifactRoundTrip)
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", testWorkflowDispatch)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
|
||||
@@ -160,7 +160,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
sharedNet, _ = selfNetwork(ctx, cli)
|
||||
)
|
||||
if sharedNet != "" {
|
||||
baseURL = "http://" + net.JoinHostPort(name, "3000")
|
||||
baseURL = fmt.Sprintf("http://%s:3000", name)
|
||||
netConfig = &network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find a free host port: %w", err)
|
||||
}
|
||||
baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(port))
|
||||
baseURL = fmt.Sprintf("http://%s:%d", host, port)
|
||||
hostConfig.PortBindings = network.PortMap{
|
||||
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
|
||||
}
|
||||
@@ -360,6 +360,6 @@ func (f *GiteaFixture) Close(ctx context.Context) error {
|
||||
if f.id == "" {
|
||||
return f.cli.Close()
|
||||
}
|
||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true})
|
||||
return errors.Join(removeErr, f.cli.Close())
|
||||
}
|
||||
@@ -27,7 +27,6 @@ type runnerOptions struct {
|
||||
capacity int
|
||||
ephemeral bool
|
||||
cacheV2 *bool
|
||||
cacheHost string
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||
@@ -50,7 +49,6 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
||||
cfg.Container.DockerHost = "unix:///var/run/docker.sock"
|
||||
}
|
||||
cfg.Cache.Dir = t.TempDir() + "/cache"
|
||||
cfg.Cache.Host = options.cacheHost
|
||||
cfg.Runner.Insecure = true
|
||||
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
|
||||
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
|
||||
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
name: artifact
|
||||
on: push
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- run: |
|
||||
echo "results=$ACTIONS_RESULTS_URL cache=$ACTIONS_CACHE_URL v2=${ACTIONS_CACHE_SERVICE_V2:-unset}"
|
||||
if [ "$ACTIONS_CACHE_SERVICE_V2" = "true" ]; then
|
||||
test "$ACTIONS_RESULTS_URL/" = "$ACTIONS_CACHE_URL"
|
||||
else
|
||||
test "$ACTIONS_RESULTS_URL/" != "$ACTIONS_CACHE_URL"
|
||||
fi
|
||||
mkdir -p out
|
||||
echo "artifact-payload" > out/data.txt
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-artifact-${{ github.run_id }}
|
||||
path: out/data.txt
|
||||
download:
|
||||
needs: upload
|
||||
runs-on: e2e-artifact
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-artifact-${{ github.run_id }}
|
||||
path: got
|
||||
- run: grep -q artifact-payload got/data.txt
|
||||
@@ -2,50 +2,50 @@ module gitea.com/gitea/runner
|
||||
|
||||
go 1.27
|
||||
|
||||
toolchain go1.27.1
|
||||
toolchain go1.27.0
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.21.0
|
||||
connectrpc.com/connect v1.20.0
|
||||
dario.cat/mergo v1.0.2
|
||||
gitea.dev/actionslib v1.0.0
|
||||
gitea.dev/actionslib v0.7.0
|
||||
github.com/avast/retry-go/v5 v5.0.0
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v29.8.0+incompatible
|
||||
github.com/docker/cli v29.7.2+incompatible
|
||||
github.com/docker/go-connections v0.8.1
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/go-git/go-billy/v5 v5.9.1
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/gobwas/glob v1.0.0
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
github.com/moby/go-archive v0.3.3
|
||||
github.com/moby/moby/api v1.56.0
|
||||
github.com/moby/moby/client v0.6.0
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/moby/patternmatcher v0.6.1
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/opencontainers/selinux v1.15.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/prometheus/client_model v0.6.3
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/sirupsen/logrus v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gotest.tools/v3 v3.5.2
|
||||
tags.cncf.io/container-device-interface v1.1.1
|
||||
tags.cncf.io/container-device-interface v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -55,12 +55,14 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.5 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.8 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
@@ -73,6 +75,9 @@ require (
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.28 // indirect
|
||||
github.com/mattn/go-shellwords v1.0.14 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.1 // indirect
|
||||
@@ -82,17 +87,21 @@ require (
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
|
||||
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||
cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik=
|
||||
cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY=
|
||||
gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g=
|
||||
gitea.dev/actionslib v0.7.0 h1:JCV8eeIGwjlXcuSr7ojEdQC22VoE2466+K+D9vuQWKQ=
|
||||
gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
@@ -25,6 +25,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -42,10 +44,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk=
|
||||
github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
|
||||
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
|
||||
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||
@@ -56,6 +56,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
@@ -75,8 +77,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
|
||||
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -110,16 +112,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-shellwords v1.0.14 h1:yUKzIgsCnosndOASY6/enly1EAuaXeFSQ7cdyA3OuYg=
|
||||
github.com/mattn/go-shellwords v1.0.14/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
|
||||
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
|
||||
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||
@@ -149,22 +157,24 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
||||
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
|
||||
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -178,6 +188,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -188,6 +199,13 @@ github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 h1:zjNCuOOhh1T
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
@@ -213,18 +231,18 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -233,14 +251,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -259,5 +277,5 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
tags.cncf.io/container-device-interface v1.1.1 h1:YPwQz4xg8PlQ0yT/baR0BtLpTQROe4l6M1yuRgAu1vc=
|
||||
tags.cncf.io/container-device-interface v1.1.1/go.mod h1:S1PSJWYPD4Iom0/39mvr/VVFCfG0Yt14j20d0OYrY2M=
|
||||
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY=
|
||||
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
|
||||
@@ -6,6 +6,8 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
@@ -65,8 +67,10 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
|
||||
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
|
||||
|
||||
<-cmd.Context().Done()
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt)
|
||||
<-c
|
||||
|
||||
return cacheHandler.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func Execute(ctx context.Context) {
|
||||
// hide completion command
|
||||
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
||||
|
||||
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -188,10 +187,7 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||
|
||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||
if envs["ACTIONS_RESULTS_URL"] == "" {
|
||||
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
|
||||
}
|
||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
|
||||
return envs
|
||||
@@ -544,7 +540,6 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
config.Env["ACT_EXEC"] = "true"
|
||||
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||
|
||||
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
|
||||
config.Token = t
|
||||
|
||||
@@ -12,12 +12,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -310,19 +307,3 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
require.NoError(t, r.Close())
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
|
||||
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
args := &executeArgs{cacheHandler: handler}
|
||||
|
||||
envs := args.LoadEnvs()
|
||||
assert.Equal(t, handler.ExternalURL(), envs["ACTIONS_RESULTS_URL"],
|
||||
"the v2 flag is advertised, so something has to serve that origin")
|
||||
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||
|
||||
args.envs = []string{"ACTIONS_RESULTS_URL=https://gitea.example"}
|
||||
assert.Equal(t, "https://gitea.example", args.LoadEnvs()["ACTIONS_RESULTS_URL"], "a supplied origin wins")
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func TestRegisterInputsValidate(t *testing.T) {
|
||||
|
||||
func TestValidateLabels(t *testing.T) {
|
||||
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
||||
require.NoError(t, validateLabels([]string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}))
|
||||
// a colon that is not a supported schema is part of the label name
|
||||
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
||||
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
||||
|
||||
+44
-100
@@ -11,8 +11,6 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -68,8 +66,6 @@ type Runner struct {
|
||||
cacheHandler *artifactcache.Handler
|
||||
capabilities string
|
||||
|
||||
isolatedCacheNetwork func() string
|
||||
|
||||
runningTasks sync.Map
|
||||
runningCount atomic.Int64
|
||||
lastIdleCleanupUnixNano atomic.Int64
|
||||
@@ -93,7 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||
maps.Copy(envs, cfg.Runner.Envs)
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cacheEnabled(cfg) {
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
warnIgnoredCachePolicy(cfg)
|
||||
// The v1 client appends its path to this without a separator, so the slash is required.
|
||||
@@ -137,7 +133,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
now: time.Now,
|
||||
runHealthCheck: executeHealthCheck,
|
||||
}
|
||||
runner.isolatedCacheNetwork = sync.OnceValue(runner.detectIsolatedCacheNetwork)
|
||||
return runner
|
||||
}
|
||||
|
||||
@@ -149,7 +144,8 @@ func (r *Runner) Close() error {
|
||||
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
||||
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
||||
|
||||
var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes
|
||||
// removeOrphanMacOSVMs is a variable so tests can substitute one that needs no macOS VM executor.
|
||||
var removeOrphanMacOSVMs = container.CleanupOrphanMacOSVMs
|
||||
|
||||
// OnIdle performs lightweight maintenance during polling idle windows.
|
||||
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
||||
@@ -172,19 +168,31 @@ func (r *Runner) OnIdle(ctx context.Context) {
|
||||
if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" {
|
||||
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||
}
|
||||
r.cleanupOrphanDockerResources(ctx)
|
||||
r.cleanupOrphanNetworks(ctx)
|
||||
r.cleanupOrphanMacOSVMs(ctx)
|
||||
}
|
||||
|
||||
func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
||||
if r.uuid == "" || (!r.requiresDocker() && !dockerReachable(ctx)) {
|
||||
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
|
||||
// tear down. A labelled network with no containers on it is finished with, and as for the
|
||||
// directories above, a task beginning during the pass is safe because the cutoff keeps a
|
||||
// network it has created but not yet attached a container to out of scope.
|
||||
func (r *Runner) cleanupOrphanNetworks(ctx context.Context) {
|
||||
if r.uuid == "" || !r.requiresDocker() {
|
||||
return
|
||||
}
|
||||
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
||||
if err := removeOrphanNetworks(ctx, r.uuid, cutoff); err != nil {
|
||||
log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
||||
}
|
||||
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
||||
log.Warnf("failed to clean up volumes left behind by earlier jobs: %v", err)
|
||||
}
|
||||
|
||||
func (r *Runner) cleanupOrphanMacOSVMs(ctx context.Context) {
|
||||
if r.uuid == "" || !r.labels.RequireMacOSVM() || r.cfg.Runner.WorkdirCleanupAge <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
||||
if err := removeOrphanMacOSVMs(ctx, r.cfg.MacOSVM.ExecutorPath, r.uuid, cutoff); err != nil {
|
||||
log.Warnf("failed to clean up macOS VMs left behind by earlier jobs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,13 +293,13 @@ func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.Decl
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||
if _, ok := r.runningTasks.LoadOrStore(task.Id, struct{}{}); ok {
|
||||
if _, ok := r.runningTasks.Load(task.Id); ok {
|
||||
return fmt.Errorf("task %d is already running", task.Id)
|
||||
}
|
||||
r.runningTasks.Store(task.Id, struct{}{})
|
||||
defer r.runningTasks.Delete(task.Id)
|
||||
|
||||
r.runningCount.Add(1)
|
||||
defer r.runningCount.Add(-1)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
@@ -299,25 +307,15 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||
defer cancel()
|
||||
// A proxy URL may carry credentials, and every job is given it; keep them out of the log.
|
||||
reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...)
|
||||
var volumeCleanup []common.Executor
|
||||
var volumeCleanupMu sync.Mutex
|
||||
if r.cfg.Runner.PostTaskScript == "" {
|
||||
ctx = runner.WithJobVolumeCleanup(ctx, func(cleanup common.Executor) {
|
||||
volumeCleanupMu.Lock()
|
||||
defer volumeCleanupMu.Unlock()
|
||||
volumeCleanup = append(volumeCleanup, cleanup)
|
||||
})
|
||||
}
|
||||
var runErr error
|
||||
defer func() {
|
||||
r.runningCount.Add(-1)
|
||||
|
||||
lastWords := ""
|
||||
if runErr != nil {
|
||||
lastWords = runErr.Error()
|
||||
}
|
||||
_ = reporter.Close(lastWords)
|
||||
if err := cleanupJobVolumes(ctx, volumeCleanup); err != nil {
|
||||
log.Warnf("task %d volume cleanup after reporting: %v", task.Id, err)
|
||||
}
|
||||
|
||||
metrics.JobDuration.Observe(time.Since(start).Seconds())
|
||||
metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc()
|
||||
@@ -328,16 +326,6 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupJobVolumes(ctx context.Context, cleanups []common.Executor) error {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
var errs []error
|
||||
for _, cleanup := range cleanups {
|
||||
errs = append(errs, cleanup(ctx))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (r *Runner) cloneEnvs() map[string]string {
|
||||
// Reserve space for the per-task keys injected by run():
|
||||
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
|
||||
@@ -414,7 +402,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
|
||||
// Added per task because this job's service containers must be reached directly, and
|
||||
// act reaches them by their workflow key.
|
||||
proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services)))
|
||||
proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services)))
|
||||
maps.Copy(envs, proxyEnv)
|
||||
|
||||
if r.capabilities != "" {
|
||||
@@ -480,6 +468,14 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
// is that server's responsibility to authenticate requests.
|
||||
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
|
||||
defer revokeCache()
|
||||
// A cache server that agreed to forward the artifact half is the whole results service, so the
|
||||
// job is pointed at it.
|
||||
if resultsURL != "" {
|
||||
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
||||
if r.cacheServiceV2() {
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
eventJSON, err := json.Marshal(preset.Event)
|
||||
if err != nil {
|
||||
@@ -520,13 +516,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return fallbackPlatform()
|
||||
}
|
||||
|
||||
if resultsURL != "" && r.cacheIsolatedFrom(job, platformPicker) {
|
||||
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf("jobs cannot reach the cache server at %s on docker network %q, so caching fails and artifacts go to Gitea directly, set cache.host and cache.port to an address jobs reach, or container.network to %[2]q",
|
||||
r.cacheHandler.ExternalURL(), r.isolatedCacheNetwork())))
|
||||
resultsURL = ""
|
||||
}
|
||||
r.setResultsService(envs, resultsURL)
|
||||
|
||||
runnerConfig := &runner.Config{
|
||||
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
|
||||
// On Windows, Workdir will be like "\<parent_directory>\<owner>\<repo>"
|
||||
@@ -573,8 +562,16 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
Vars: task.Vars,
|
||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
RunnerName: r.name,
|
||||
MacOSVM: runner.MacOSVMConfig{
|
||||
ExecutorPath: r.cfg.MacOSVM.ExecutorPath,
|
||||
WorkdirParent: r.cfg.MacOSVM.WorkdirParent,
|
||||
CPU: r.cfg.MacOSVM.CPU,
|
||||
Memory: r.cfg.MacOSVM.Memory,
|
||||
BootTimeout: r.cfg.MacOSVM.BootTimeout,
|
||||
},
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
RunnerUUID: r.uuid,
|
||||
RunnerName: r.name,
|
||||
}
|
||||
|
||||
rr, err := runner.New(runnerConfig)
|
||||
@@ -609,43 +606,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return execErr
|
||||
}
|
||||
|
||||
func (r *Runner) builtInCacheURL() string {
|
||||
if r.cacheHandler == nil {
|
||||
return ""
|
||||
}
|
||||
return r.envs["ACTIONS_CACHE_URL"]
|
||||
}
|
||||
|
||||
func cacheEnabled(cfg *config.Config) bool {
|
||||
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
|
||||
}
|
||||
|
||||
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
|
||||
// turns off: the bundle edit that reaches it is what the artifact actions need too.
|
||||
func (r *Runner) cacheServiceV2() bool {
|
||||
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
|
||||
}
|
||||
|
||||
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
||||
v2 := resultsURL != "" && r.cacheServiceV2()
|
||||
if v2 {
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
} else {
|
||||
delete(envs, runner.CacheServiceV2Env)
|
||||
}
|
||||
// Cache v2 clients read no other variable for it, and some cannot use the instance address.
|
||||
if v2 || (resultsURL != "" && r.instanceOutOfReach(envs["ACTIONS_RESULTS_URL"])) {
|
||||
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
||||
}
|
||||
}
|
||||
|
||||
// These clients keep only the origin they are handed, and reach it on the job's own trust.
|
||||
func (r *Runner) instanceOutOfReach(instance string) bool {
|
||||
parsed, err := url.Parse(instance)
|
||||
return err != nil || strings.Trim(parsed.Path, "/") != "" ||
|
||||
(r.cfg.Runner.Insecure && parsed.Scheme == "https")
|
||||
}
|
||||
|
||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||
// with the given runtime token for the duration of this task. Returns a
|
||||
// function the caller must invoke (typically via defer) to revoke the
|
||||
@@ -672,7 +638,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
||||
if r.cacheHandler != nil {
|
||||
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
|
||||
}
|
||||
if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
return r.registerExternalCacheJob(token, cred, reporter)
|
||||
}
|
||||
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
||||
@@ -855,25 +821,3 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
}
|
||||
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
||||
}
|
||||
|
||||
func (r *Runner) cacheIsolatedFrom(job *model.Job, pickPlatform func([]string) string) bool {
|
||||
jobContainer := job.Container()
|
||||
return (jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform) && r.isolatedCacheNetwork() != ""
|
||||
}
|
||||
|
||||
func (r *Runner) detectIsolatedCacheNetwork() string {
|
||||
if r.cacheHandler == nil {
|
||||
return ""
|
||||
}
|
||||
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
network, err := container.IsolatedNetwork(ctx, addr, r.cfg.Container.Network)
|
||||
if err != nil {
|
||||
log.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
||||
}
|
||||
return network
|
||||
}
|
||||
@@ -194,13 +194,6 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be unknown to the remote server before registration")
|
||||
|
||||
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{
|
||||
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
|
||||
}}}
|
||||
disabled.registerCacheForTask(token, repo, nil)
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"a disabled cache registers nothing, whatever external server is left configured")
|
||||
|
||||
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
|
||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token must be accepted after registerCacheForTask")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -311,7 +312,6 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
||||
}
|
||||
|
||||
var swept []string
|
||||
var sweptVolumes []string
|
||||
var sweptCutoff time.Time
|
||||
origRemoveOrphanNetworks := removeOrphanNetworks
|
||||
removeOrphanNetworks = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
@@ -320,32 +320,49 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { removeOrphanNetworks = origRemoveOrphanNetworks })
|
||||
origRemoveOrphanJobVolumes := removeOrphanJobVolumes
|
||||
removeOrphanJobVolumes = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
sweptVolumes = append(sweptVolumes, runnerUUID)
|
||||
assert.Equal(t, now.Add(-24*time.Hour), createdBefore)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
|
||||
|
||||
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
|
||||
r.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1"}, swept)
|
||||
assert.Equal(t, swept, sweptVolumes)
|
||||
// a network of a job starting during the pass is younger than this and so out of scope
|
||||
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
|
||||
|
||||
// a host-only runner has no daemon to sweep
|
||||
origDockerReachable := dockerReachable
|
||||
dockerReachable = func(context.Context) bool { return false }
|
||||
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
||||
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
||||
hostOnly.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1"}, swept)
|
||||
assert.Equal(t, swept, sweptVolumes)
|
||||
}
|
||||
|
||||
dockerReachable = func(context.Context) bool { return true }
|
||||
now = now.Add(time.Minute)
|
||||
hostOnly.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
|
||||
func TestRunnerOnIdleRemovesOrphanMacOSVMs(t *testing.T) {
|
||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
cfg := &config.Config{
|
||||
Runner: config.Runner{
|
||||
WorkdirCleanupAge: 24 * time.Hour,
|
||||
IdleCleanupInterval: time.Minute,
|
||||
},
|
||||
}
|
||||
macOSVMLabel, err := labels.Parse("macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest")
|
||||
require.NoError(t, err)
|
||||
|
||||
var swept []string
|
||||
var sweptCutoff time.Time
|
||||
origRemoveOrphanMacOSVMs := removeOrphanMacOSVMs
|
||||
removeOrphanMacOSVMs = func(_ context.Context, _, runnerUUID string, accessedBefore time.Time) error {
|
||||
swept = append(swept, runnerUUID)
|
||||
sweptCutoff = accessedBefore
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { removeOrphanMacOSVMs = origRemoveOrphanMacOSVMs })
|
||||
|
||||
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{macOSVMLabel}, now: func() time.Time { return now }}
|
||||
r.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1"}, swept)
|
||||
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
|
||||
|
||||
// a docker-only runner has no macOS VMs to sweep
|
||||
dockerLabel, err := labels.Parse("ubuntu:docker://node:18")
|
||||
require.NoError(t, err)
|
||||
dockerOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, labels: labels.Labels{dockerLabel}, now: func() time.Time { return now }}
|
||||
dockerOnly.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1"}, swept)
|
||||
}
|
||||
@@ -5,18 +5,10 @@ package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
@@ -24,7 +16,6 @@ import (
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -82,113 +73,6 @@ func TestRunnerRunningCountAndNullLogger(t *testing.T) {
|
||||
require.NotNil(t, logger.Out)
|
||||
}
|
||||
|
||||
func TestRunnerReclaimsVolumesAfterReporting(t *testing.T) {
|
||||
for _, mode := range []string{"deferred", "post-task script"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Cache: config.Cache{Enabled: new(false)},
|
||||
Runner: config.Runner{Timeout: time.Minute, LogReportInterval: time.Minute, StateReportInterval: time.Minute},
|
||||
Container: config.Container{Network: "host", DockerHost: "-"},
|
||||
}
|
||||
if mode == "post-task script" {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX script")
|
||||
}
|
||||
cfg.Runner.PostTaskScript = filepath.Join(t.TempDir(), "post-task.sh")
|
||||
require.NoError(t, os.WriteFile(cfg.Runner.PostTaskScript, []byte("#!/bin/sh\n: > \"$0.done\"\n"), 0o700))
|
||||
}
|
||||
cli := clientmocks.NewClient(t)
|
||||
cli.AddressValue = "https://gitea.example/"
|
||||
r := NewRunner(cfg, &config.Registration{UUID: "runner-1", Labels: []string{"ubuntu:docker://node:20"}}, cli)
|
||||
var reported atomic.Bool
|
||||
var removed atomic.Int64
|
||||
cli.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||
assert.False(t, reported.Load())
|
||||
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
|
||||
})
|
||||
cli.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, req.Msg.State.Result)
|
||||
assert.Equal(t, int64(1), r.RunningCount())
|
||||
if mode == "post-task script" {
|
||||
assert.FileExists(t, cfg.Runner.PostTaskScript+".done")
|
||||
}
|
||||
reported.Store(true)
|
||||
}
|
||||
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
|
||||
})
|
||||
var volumesCreated atomic.Bool
|
||||
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("API-Version", "1.47")
|
||||
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||
if response, ok := map[string]string{
|
||||
"/_ping": "OK",
|
||||
"/info": `{"Architecture":"amd64","OSType":"linux"}`,
|
||||
"/containers/json": "[]",
|
||||
"/networks": "[]",
|
||||
"/volumes": `{"Volumes":[]}`,
|
||||
"/containers/create": `{"Id":"job-id"}`,
|
||||
"/containers/job-id/json": `{"Id":"job-id","Config":{},"State":{"Status":"running"}}`,
|
||||
}[path]; ok {
|
||||
_, _ = io.WriteString(writer, response)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/images/"):
|
||||
_, _ = io.WriteString(writer, `{"Id":"image-id","Config":{},"Os":"linux","Architecture":"amd64"}`)
|
||||
case path == "/volumes/create":
|
||||
volumesCreated.Store(true)
|
||||
_, _ = io.WriteString(writer, "{}")
|
||||
case strings.HasSuffix(path, "/exec"):
|
||||
_, _ = io.WriteString(writer, `{"Id":"exec-id"}`)
|
||||
case strings.HasPrefix(path, "/exec/"):
|
||||
_, _ = io.WriteString(writer, `{"Running":false,"ExitCode":0}`)
|
||||
case request.Method == http.MethodDelete || strings.HasSuffix(path, "/start") || strings.HasSuffix(path, "/kill") || strings.HasSuffix(path, "/archive"):
|
||||
if request.Method == http.MethodDelete && strings.HasPrefix(path, "/volumes/") && volumesCreated.Load() {
|
||||
assert.Equal(t, mode != "post-task script", reported.Load())
|
||||
if mode == "post-task script" {
|
||||
assert.NoFileExists(t, cfg.Runner.PostTaskScript+".done")
|
||||
}
|
||||
assert.Equal(t, int64(1), r.RunningCount())
|
||||
removed.Add(1)
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(daemon.Close)
|
||||
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||
require.NoError(t, r.Run(t.Context(), &runnerv1.Task{
|
||||
Context: &structpb.Struct{},
|
||||
WorkflowPayload: []byte("jobs:\n job:\n runs-on: ubuntu\n steps:\n - run: exit 0\n if: false\n"),
|
||||
}))
|
||||
assert.True(t, reported.Load())
|
||||
assert.Equal(t, int64(2), removed.Load())
|
||||
assert.Zero(t, r.RunningCount())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupJobVolumesJoinsErrorsAfterCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
err := cleanupJobVolumes(ctx, []common.Executor{
|
||||
func(ctx context.Context) error {
|
||||
require.NoError(t, ctx.Err())
|
||||
deadline, ok := ctx.Deadline()
|
||||
assert.True(t, ok)
|
||||
assert.InDelta(t, time.Minute.Seconds(), time.Until(deadline).Seconds(), 1)
|
||||
return io.EOF
|
||||
},
|
||||
func(context.Context) error { return io.ErrClosedPipe },
|
||||
})
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
require.ErrorIs(t, err, io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
||||
cacheEnabled := false
|
||||
cfg := &config.Config{}
|
||||
@@ -259,7 +143,6 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
||||
|
||||
require.NotContains(t, r.envs, "http_proxy")
|
||||
require.NotContains(t, r.envs, "no_proxy")
|
||||
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours")
|
||||
}
|
||||
|
||||
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
||||
@@ -306,46 +189,11 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
|
||||
|
||||
envs := r.cloneEnvs()
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"])
|
||||
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||
|
||||
// Turning v2 off withdraws the advertisement and nothing else.
|
||||
assert.True(t, r.cacheServiceV2())
|
||||
cfg.Cache.V2 = new(bool)
|
||||
envs = r.cloneEnvs()
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, "https://gitea.example", envs["ACTIONS_RESULTS_URL"])
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env], "a runner.envs entry would promise v2 at an origin not serving it")
|
||||
|
||||
envs = r.cloneEnvs()
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
envs["ACTIONS_RESULTS_URL"] = "https://gitea.example/sub"
|
||||
r.setResultsService(envs, "")
|
||||
assert.Equal(t, "https://gitea.example/sub", envs["ACTIONS_RESULTS_URL"], "with no cache server there is nothing to front with")
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||
|
||||
for instance, insecure := range map[string]bool{
|
||||
"https://gitea.example/sub": false,
|
||||
"https://self-signed.example": true,
|
||||
} {
|
||||
cfg.Runner.Insecure = insecure
|
||||
envs = r.cloneEnvs()
|
||||
envs["ACTIONS_RESULTS_URL"] = instance
|
||||
r.setResultsService(envs, resultsURL)
|
||||
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
|
||||
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||
}
|
||||
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
||||
require.NoError(t, err)
|
||||
r.isolatedCacheNetwork = func() string { return "compose" }
|
||||
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
||||
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
||||
assert.Equal(t, isolated, r.cacheIsolatedFrom(workflow.GetJob(job), pickPlatform), job)
|
||||
}
|
||||
assert.False(t, r.cacheServiceV2())
|
||||
}
|
||||
|
||||
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
|
||||
|
||||
@@ -53,12 +53,11 @@ runner:
|
||||
# scratch directories (left behind when a host cleanup delete stalls) older than
|
||||
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
||||
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
||||
# the docker network and volume cleanup below.
|
||||
# the docker network cleanup below.
|
||||
#workdir_cleanup_age: 24h
|
||||
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
||||
# docker it removes the per-job networks and volumes of jobs this runner did not live to
|
||||
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
|
||||
# workspace contents until the host is rebuilt.
|
||||
# docker it removes the per-job networks of jobs this runner did not live to tear down,
|
||||
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt.
|
||||
#idle_cleanup_interval: 10m
|
||||
# The base interval for periodic log flush to the Gitea instance.
|
||||
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
||||
@@ -93,7 +92,8 @@ runner:
|
||||
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
|
||||
#patch_actions: true
|
||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# Like: "macos-arm64:host", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||
# or "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||
# If it's empty when registering, it will ask for inputting labels.
|
||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||
@@ -140,8 +140,7 @@ runner:
|
||||
# job_completed: ''
|
||||
|
||||
cache:
|
||||
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
|
||||
# started and no job is registered with an external_server either.
|
||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||
#enabled: true
|
||||
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||
# Ignored when external_server is set.
|
||||
@@ -177,11 +176,10 @@ cache:
|
||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||
# until its cache entry expires or is manually removed.
|
||||
#offline_mode: false
|
||||
# Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
|
||||
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
|
||||
# put back after the copy into the job. That edit is made either way, this only governs the API
|
||||
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
|
||||
# cache server unless container jobs cannot reach its Docker network.
|
||||
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not
|
||||
# take for GitHub, so reaching it means editing that check out of their own bundle, undone
|
||||
# whenever it is downloaded again. That edit is made either way, this only governs the API
|
||||
# advertised. A bundle that does not match is left alone.
|
||||
#v2: true
|
||||
# How the cache server discards entries, ignored when external_server is set since that
|
||||
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
|
||||
@@ -259,11 +257,11 @@ container:
|
||||
#require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
#docker_timeout: 0s
|
||||
# Mount the workspace from a host directory instead of a Docker volume, so jobs
|
||||
# can bind-mount it by its own path into sibling containers (".:/app" in docker
|
||||
# compose). Not needed when jobs get the Docker proxy or workflows use the
|
||||
# GITEA_DOCKER_WORKSPACE variable.
|
||||
# The workspace parent directory must be mounted into the runner container.
|
||||
# Bind the workspace to the host filesystem instead of using Docker volumes.
|
||||
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
||||
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
||||
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
||||
# directory is also mounted into the runner container.
|
||||
#bind_workdir: false
|
||||
# How long a job waits for a service container that declares a healthcheck to become
|
||||
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
||||
@@ -274,6 +272,19 @@ host:
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
#workdir_parent:
|
||||
|
||||
macos_vm:
|
||||
# Path to the runner-executor-macos binary. Empty uses PATH lookup.
|
||||
#executor_path: runner-executor-macos
|
||||
# The guest directory under which the job workspace is created. The VM must run
|
||||
# as a user that can write there.
|
||||
#workdir_parent: /Users/admin/runner
|
||||
# Number of virtual CPUs. Omit or set to 0 to keep the image default.
|
||||
#cpu: 0
|
||||
# VM memory in megabytes. Omit or set to 0 to keep the image default.
|
||||
#memory: 0
|
||||
# How long to wait for the VM to become ready after it starts.
|
||||
#boot_timeout: 5m
|
||||
|
||||
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||
# No health checks run while any job is active; the last result is reused until idle.
|
||||
|
||||
@@ -63,8 +63,8 @@ type Runner struct {
|
||||
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
||||
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
||||
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories, orphaned host-mode scratch dirs and orphaned docker job resources older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks and volumes) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
|
||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||
@@ -157,7 +157,7 @@ type Container struct {
|
||||
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
|
||||
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
|
||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
|
||||
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
|
||||
}
|
||||
|
||||
@@ -179,6 +179,15 @@ type Host struct {
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||
}
|
||||
|
||||
// MacOSVM represents the configuration for the macOS VM executor.
|
||||
type MacOSVM struct {
|
||||
ExecutorPath string `yaml:"executor_path"` // ExecutorPath is the runner-executor-macos binary to invoke.
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent is the guest directory under which the job workspace is created.
|
||||
CPU int `yaml:"cpu"` // CPU is the number of virtual CPUs. Zero keeps the image default.
|
||||
Memory int `yaml:"memory"` // Memory is the VM memory in megabytes. Zero keeps the image default.
|
||||
BootTimeout time.Duration `yaml:"boot_timeout"` // BootTimeout bounds how long to wait for the VM to become ready.
|
||||
}
|
||||
|
||||
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||
type Metrics struct {
|
||||
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
||||
@@ -203,6 +212,7 @@ type Config struct {
|
||||
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
|
||||
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
||||
MacOSVM MacOSVM `yaml:"macos_vm"` // MacOSVM represents the configuration for the macOS VM executor.
|
||||
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
|
||||
}
|
||||
@@ -300,6 +310,15 @@ func LoadDefault(file string) (*Config, error) {
|
||||
}
|
||||
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
||||
}
|
||||
if cfg.MacOSVM.ExecutorPath == "" {
|
||||
cfg.MacOSVM.ExecutorPath = "runner-executor-macos"
|
||||
}
|
||||
if cfg.MacOSVM.WorkdirParent == "" {
|
||||
cfg.MacOSVM.WorkdirParent = "/Users/admin/runner"
|
||||
}
|
||||
if cfg.MacOSVM.BootTimeout <= 0 {
|
||||
cfg.MacOSVM.BootTimeout = 5 * time.Minute
|
||||
}
|
||||
if cfg.Runner.FetchTimeout <= 0 {
|
||||
cfg.Runner.FetchTimeout = 5 * time.Second
|
||||
}
|
||||
@@ -391,10 +410,10 @@ func warnUnknownKeys(file string, content []byte) {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||
decoder.KnownFields(true)
|
||||
|
||||
var loadErrs *yaml.LoadErrors
|
||||
if err := decoder.Decode(&Config{}); errors.As(err, &loadErrs) {
|
||||
for _, loadErr := range loadErrs.Errors {
|
||||
log.Warnf("config file %q: %s, it will be ignored", file, loadErr)
|
||||
var typeErr *yaml.TypeError
|
||||
if err := decoder.Decode(&Config{}); errors.As(err, &typeErr) {
|
||||
for _, message := range typeErr.Errors {
|
||||
log.Warnf("config file %q: %s, it will be ignored", file, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,31 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
|
||||
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
|
||||
}
|
||||
|
||||
func TestLoadDefault_MacOSVMDefaults(t *testing.T) {
|
||||
cfg, err := LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "runner-executor-macos", cfg.MacOSVM.ExecutorPath)
|
||||
assert.Equal(t, "/Users/admin/runner", cfg.MacOSVM.WorkdirParent)
|
||||
assert.Equal(t, 5*time.Minute, cfg.MacOSVM.BootTimeout)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
macos_vm:
|
||||
executor_path: /usr/local/bin/runner-executor-macos
|
||||
workdir_parent: /Users/runner
|
||||
cpu: 4
|
||||
memory: 8192
|
||||
boot_timeout: 3m
|
||||
`), 0o600))
|
||||
cfg, err = LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/usr/local/bin/runner-executor-macos", cfg.MacOSVM.ExecutorPath)
|
||||
assert.Equal(t, "/Users/runner", cfg.MacOSVM.WorkdirParent)
|
||||
assert.Equal(t, 4, cfg.MacOSVM.CPU)
|
||||
assert.Equal(t, 8192, cfg.MacOSVM.Memory)
|
||||
assert.Equal(t, 3*time.Minute, cfg.MacOSVM.BootTimeout)
|
||||
}
|
||||
|
||||
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
|
||||
cfg, err := LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -312,19 +312,7 @@ func allScalars(nodes []*yaml.Node) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// quoteLeadingNewlines keeps a scalar starting with a newline out of block style, whose indentation indicator the encoder drops.
|
||||
// TODO: remove once https://github.com/yaml/go-yaml/pull/396 is released.
|
||||
func quoteLeadingNewlines(node *yaml.Node) {
|
||||
if node.Kind == yaml.ScalarNode && strings.HasPrefix(node.Value, "\n") {
|
||||
node.Style = yaml.DoubleQuotedStyle
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
quoteLeadingNewlines(child)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeYAML(node *yaml.Node) ([]byte, error) {
|
||||
quoteLeadingNewlines(node)
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
|
||||
@@ -74,13 +74,6 @@ func TestEditValues(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set map entry starting with a newline before an indented line",
|
||||
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "\n indented\nplain\n") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, "\n indented\nplain\n", cfg.Runner.Envs["ADDED"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set replaces a list",
|
||||
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
|
||||
|
||||
@@ -9,11 +9,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemeHost = "host"
|
||||
SchemeDocker = "docker"
|
||||
SchemeHost = "host"
|
||||
SchemeDocker = "docker"
|
||||
SchemeMacOSVM = "macos-vm"
|
||||
|
||||
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
||||
SelfHostedPlatform = "-self-hosted"
|
||||
|
||||
// MacOSVMSchemePrefix marks a platform image that act should run in a macOS VM.
|
||||
MacOSVMSchemePrefix = "macos-vm://"
|
||||
)
|
||||
|
||||
type Label struct {
|
||||
@@ -42,7 +46,7 @@ func Parse(str string) (*Label, error) {
|
||||
if len(splits) >= 3 {
|
||||
label.Arg = splits[2]
|
||||
}
|
||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
|
||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker && label.Schema != SchemeMacOSVM {
|
||||
// Not a schema we know: the colon belongs to the label name itself.
|
||||
return &Label{
|
||||
Name: str,
|
||||
@@ -64,6 +68,15 @@ func (l Labels) RequireDocker() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (l Labels) RequireMacOSVM() bool {
|
||||
for _, label := range l {
|
||||
if label.Schema == SchemeMacOSVM {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
|
||||
func (l Labels) PickPlatform(runsOn []string) string {
|
||||
platforms := make(map[string]string, len(l))
|
||||
@@ -72,10 +85,12 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
||||
case SchemeDocker:
|
||||
// "//" will be ignored
|
||||
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
||||
case SchemeMacOSVM:
|
||||
platforms[label.Name] = MacOSVMSchemePrefix + strings.TrimPrefix(label.Arg, "//")
|
||||
case SchemeHost:
|
||||
platforms[label.Name] = SelfHostedPlatform
|
||||
default:
|
||||
// unreachable: Parse only produces host or docker schemas
|
||||
// unreachable: Parse only produces host, docker, or macOS VM schemas
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ func TestParse(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
args: "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
want: &Label{
|
||||
Name: "macos-latest",
|
||||
Schema: "macos-vm",
|
||||
Arg: "//ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
args: "ubuntu:host",
|
||||
want: &Label{
|
||||
@@ -103,6 +112,7 @@ func TestRequireDocker(t *testing.T) {
|
||||
{"empty", nil, false},
|
||||
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
|
||||
{"macos-vm does not require docker", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -111,10 +121,29 @@ func TestRequireDocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMacOSVM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
strs []string
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||
{"only docker", []string{"ubuntu:docker://node:18"}, false},
|
||||
{"has macos-vm", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireMacOSVM())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPlatform(t *testing.T) {
|
||||
ls := mustParse(t,
|
||||
"ubuntu:docker://node:18",
|
||||
"self-hosted:host",
|
||||
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
@@ -124,6 +153,7 @@ func TestPickPlatform(t *testing.T) {
|
||||
}{
|
||||
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
||||
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
|
||||
{"macos-vm prefixes platform marker", []string{"macos-latest"}, MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"},
|
||||
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
|
||||
{"unknown label picks nothing", []string{"windows"}, ""},
|
||||
{"no runsOn picks nothing", nil, ""},
|
||||
@@ -145,12 +175,14 @@ func TestToStrings(t *testing.T) {
|
||||
ls := mustParse(t,
|
||||
"ubuntu:docker://node:18",
|
||||
"self-hosted:host",
|
||||
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
"bare",
|
||||
"pool:e57e18d4",
|
||||
)
|
||||
require.Equal(t, []string{
|
||||
"ubuntu:docker://node:18",
|
||||
"self-hosted:host",
|
||||
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
"bare:host",
|
||||
"pool:e57e18d4",
|
||||
}, ls.ToStrings())
|
||||
|
||||
@@ -3,4 +3,10 @@
|
||||
"extends": [
|
||||
"local>gitea/renovate-config"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["go.yaml.in/yaml/v4"],
|
||||
"allowedVersions": "<4.0.0-rc.4", // rc.4 removes the error types actionlint builds against
|
||||
},
|
||||
],
|
||||
}
|
||||
+9
-59
@@ -9,8 +9,10 @@
|
||||
#
|
||||
# Usage: scripts/test-dind.sh [target] [-- go-test-args...]
|
||||
# target: dind (default) or dind-rootless
|
||||
# go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
|
||||
# symlink copying and a mounted Docker job using cached images.
|
||||
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests
|
||||
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't
|
||||
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
|
||||
# /var/run symlink copy regression (which reuses a preloaded alpine).
|
||||
#
|
||||
# Env:
|
||||
# DIND_TEST_PORT host port for the daemon (default 32375)
|
||||
@@ -23,25 +25,15 @@ case "${1:-}" in
|
||||
dind|dind-rootless) target="$1"; shift ;;
|
||||
esac
|
||||
[ "${1:-}" = "--" ] && shift
|
||||
default_tests=false
|
||||
if [ $# -eq 0 ]; then
|
||||
default_tests=true
|
||||
set -- -count=1 -race -run '^TestDocker$' ./act/container/
|
||||
fi
|
||||
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
|
||||
|
||||
port="${DIND_TEST_PORT:-32375}"
|
||||
name="gitea-runner-dind-test-$$"
|
||||
image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}"
|
||||
# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon.
|
||||
host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
|
||||
test_dir=""
|
||||
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}"
|
||||
|
||||
cleanup() {
|
||||
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
|
||||
if [ -n "$test_dir" ]; then
|
||||
rm -rf "$test_dir"
|
||||
fi
|
||||
}
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ -z "${DIND_TEST_IMAGE:-}" ]; then
|
||||
@@ -79,7 +71,7 @@ else
|
||||
fi
|
||||
# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting
|
||||
# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind.
|
||||
docker -H "$host_docker" run -d --privileged --name "$name" "${run_args[@]}" \
|
||||
docker run -d --privileged --name "$name" "${run_args[@]}" \
|
||||
-e DOCKER_TLS_CERTDIR= \
|
||||
--entrypoint dockerd-entrypoint.sh \
|
||||
"$image" --host=tcp://0.0.0.0:2375 >/dev/null
|
||||
@@ -90,20 +82,11 @@ for _ in $(seq 1 60); do
|
||||
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
if ! docker version --format 'server docker {{.Server.Version}}'; then
|
||||
docker -H "$host_docker" logs "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seed the fresh daemon with images the host already has (the CI job pulls them in the
|
||||
# preceding `make test`), so the daemon-facing tests run without registry access.
|
||||
echo "==> Seeding daemon with cached host images"
|
||||
preload="${DIND_TEST_PRELOAD:-alpine:latest}"
|
||||
job_image="${ACT_TEST_IMAGE:-node:24-bookworm-slim}"
|
||||
if [ "$default_tests" = true ]; then
|
||||
preload="$preload $job_image"
|
||||
fi
|
||||
for img in $preload; do
|
||||
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do
|
||||
if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then
|
||||
docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true
|
||||
fi
|
||||
@@ -111,36 +94,3 @@ done
|
||||
|
||||
echo "==> Running tests against dind daemon"
|
||||
go test "$@"
|
||||
|
||||
if [ "$default_tests" = true ]; then
|
||||
if ! docker image inspect "$job_image" >/dev/null 2>&1; then
|
||||
echo "mounted Docker test requires ${job_image}, pull it on the host before running this harness" >&2
|
||||
exit 1
|
||||
fi
|
||||
test_dir="$(mktemp -d)"
|
||||
echo "==> Building mounted Docker test for the dind container"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH="$(docker -H "$host_docker" image inspect "$image" --format '{{.Architecture}}')" \
|
||||
go test -c -o "$test_dir/runner.test" ./act/runner/
|
||||
docker -H "$host_docker" exec "$name" mkdir -p /tmp/gitea-runner-proxy-test/testdata
|
||||
tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \
|
||||
docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test
|
||||
socket="unix:///var/run/docker.sock"
|
||||
users=(0 1000:2375)
|
||||
if [ "$target" = dind-rootless ]; then
|
||||
socket="unix:///run/user/1000/docker.sock"
|
||||
users=(1000 0)
|
||||
fi
|
||||
for user in "${users[@]}"; do
|
||||
proxy_mode="proxy"
|
||||
if [ "$user" = 1000:2375 ]; then
|
||||
proxy_mode="direct"
|
||||
fi
|
||||
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access"
|
||||
docker -H "$host_docker" exec --user "$user" -w /tmp/gitea-runner-proxy-test \
|
||||
-e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \
|
||||
"$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
|
||||
done
|
||||
echo "==> Running mounted Docker job in a container given the ${target} socket, expecting proxy access"
|
||||
docker -H "$host_docker" exec -e DOCKER_HOST="$socket" "$name" docker run --rm -v "${socket#unix://}:/var/run/docker.sock" -v /tmp/gitea-runner-proxy-test:/data -w /data \
|
||||
-e ACT_TEST_DOCKER_PROXY=proxy -e ACT_TEST_IMAGE="$job_image" "$job_image" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
|
||||
fi
|
||||
Reference in new issue
Block a user