mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-12 19:03:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f |
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@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # 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@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # 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:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # 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].1 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
@@ -96,13 +96,8 @@ go-check:
|
||||
fi
|
||||
|
||||
.PHONY: fmt-check
|
||||
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
|
||||
fmt-check: ## check the Go code formatting
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||
|
||||
.PHONY: deps-tools
|
||||
deps-tools: ## install tool dependencies
|
||||
@@ -149,13 +144,8 @@ tidy: ## run go mod tidy
|
||||
fi
|
||||
|
||||
.PHONY: tidy-check
|
||||
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
|
||||
tidy-check: ## check that go.mod and go.sum are tidy
|
||||
$(GO) mod tidy -diff
|
||||
|
||||
.PHONY: test
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@@ -171,7 +161,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||
@./scripts/test-dind.sh $(TARGET)
|
||||
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:6dac556d980b7f0e5498d08f08cee0ca67798b4ad6c23964a9214920e67758d0 # renovate: datasource=docker
|
||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
E2E_CONCURRENCY ?= 8
|
||||
|
||||
@@ -268,14 +268,14 @@ Unlike GitHub, a job whose steps run on the host (a `host` label without `contai
|
||||
|
||||
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||
|
||||
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
|
||||
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
|
||||
```
|
||||
|
||||
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works.
|
||||
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
|
||||
|
||||
@@ -291,7 +291,7 @@ The runner uses them for its own requests and gives them to every job, in lower
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- the built-in cache server, whose address is assigned at startup
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
@@ -308,61 +308,44 @@ 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 automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
Each runner starts its own cache server, so runners do not share cached entries. When the runner itself runs in Docker, set `cache.host` to an address job containers can reach and `cache.port` to a fixed published port, or put jobs on a shared `container.network`.
|
||||
|
||||
**Sharing a cache between runners**
|
||||
|
||||
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||
|
||||
```yaml
|
||||
# cache-server.yaml
|
||||
cache:
|
||||
dir: /data/actcache
|
||||
port: 8088
|
||||
external_secret: "<secret>" # or external_secret_file: /path/to/secret
|
||||
```
|
||||
|
||||
```bash
|
||||
gitea-runner -c cache-server.yaml cache-server
|
||||
```
|
||||
|
||||
```yaml
|
||||
# each runner's config
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "<secret>"
|
||||
```
|
||||
|
||||
Jobs connect to `external_server` too, so point it at the reverse proxy if one fronts the server. `--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
|
||||
|
||||
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
|
||||
|
||||
**Eviction**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Cache service v2**
|
||||
|
||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
|
||||
|
||||
```yaml
|
||||
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.
|
||||
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.
|
||||
|
||||
#### Official Docker image
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -234,7 +233,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||
}
|
||||
|
||||
func (h *Handler) baseURL(cred JobCredential) string {
|
||||
@@ -603,12 +602,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.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||
return
|
||||
}
|
||||
cred, ok := h.lookupCredential(token)
|
||||
if !ok {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
||||
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||
@@ -617,6 +616,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, err)
|
||||
}
|
||||
|
||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||
@@ -720,6 +727,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
||||
h.responseJSON(w, r, http.StatusOK)
|
||||
}
|
||||
|
||||
// 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 "
|
||||
@@ -770,14 +783,9 @@ 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).
|
||||
And("Key").RegExp(re).
|
||||
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err != nil {
|
||||
@@ -805,8 +813,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
bolthold.Where("Key").Eq(key).Index("Key").
|
||||
And("Repo").Eq(repo).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(complete).
|
||||
SortBy(sortBy).Reverse())
|
||||
@@ -820,14 +828,16 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
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
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||
@@ -852,6 +862,9 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||
if requireIncomplete && cache.Complete {
|
||||
return fmt.Errorf("cache %d: already complete", id)
|
||||
}
|
||||
if !touchNeeded(cache) {
|
||||
return nil
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
@@ -861,9 +874,11 @@ const (
|
||||
|
||||
defaultSweepInterval = time.Hour
|
||||
|
||||
// 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
|
||||
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
|
||||
|
||||
// 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
|
||||
@@ -957,7 +972,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, inUseGrace)
|
||||
window := max(h.policy.Retention+touchStale, inUseGrace)
|
||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||
}
|
||||
|
||||
@@ -1058,10 +1073,18 @@ func inUse(cache *Cache) bool {
|
||||
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
||||
}
|
||||
|
||||
// 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,6 +52,10 @@ 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})
|
||||
@@ -767,7 +771,7 @@ func TestHandler_gcCache(t *testing.T) {
|
||||
Key: "test_key_3",
|
||||
Version: "test_version",
|
||||
Complete: true,
|
||||
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
||||
UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
|
||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||
},
|
||||
Kept: false,
|
||||
@@ -1042,6 +1046,26 @@ 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 is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
twirpInternal = "internal"
|
||||
twirpUnauthenticated = "unauthenticated"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
@@ -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_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
@@ -83,11 +83,30 @@ 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,
|
||||
@@ -107,7 +126,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_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,6 +138,13 @@ 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
|
||||
@@ -127,13 +153,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
@@ -147,7 +175,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_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,8 +258,11 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
switch code {
|
||||
case twirpInternal:
|
||||
status = http.StatusInternalServerError
|
||||
case twirpUnauthenticated:
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -141,6 +142,17 @@ 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!"),
|
||||
@@ -241,3 +253,101 @@ 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,6 +16,7 @@ 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,6 +5,8 @@ package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -23,15 +25,33 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
|
||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||
// registered with, and the 404 the router would have written otherwise.
|
||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
if !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)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||
return
|
||||
}
|
||||
if !h.mustProxy(r, cred, target) {
|
||||
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||
@@ -44,9 +64,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
// directly and never through here.
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||
},
|
||||
}
|
||||
if cred.InsecureTLS {
|
||||
@@ -55,5 +75,27 @@ 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,6 +4,8 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json/v2"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,8 +16,6 @@ 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,34 +23,112 @@ 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 = "forward-token"
|
||||
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||
|
||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||
post := func(path string) int {
|
||||
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) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
resp, err := noRedirect(token).Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
defer resp.Body.Close()
|
||||
body := map[string]string{}
|
||||
_ = json.UnmarshalRead(resp.Body, &body)
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||
"an unregistered token is forwarded nowhere")
|
||||
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"])
|
||||
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
|
||||
|
||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
||||
status, _ = post(rpc)
|
||||
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
|
||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||
assert.Equal(t, rpc, gotPath)
|
||||
|
||||
gotPath = ""
|
||||
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"))
|
||||
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.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cred JobCredential
|
||||
forwardedProto string
|
||||
reqQuery string
|
||||
want int
|
||||
location string
|
||||
wantMsg string
|
||||
}{
|
||||
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||
{
|
||||
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||
},
|
||||
{
|
||||
name: "a trusted https instance is reached by the job itself",
|
||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
|
||||
},
|
||||
{
|
||||
name: "the job does not share this server's disregard for the certificate",
|
||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
|
||||
},
|
||||
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||
{
|
||||
name: "a runner too old to send its public URL leaves the terminator to say so",
|
||||
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "a scheme the job could not use is refused before it is sent one",
|
||||
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||
},
|
||||
{
|
||||
name: "an address with no host is refused before it is sent one",
|
||||
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||
require.NoError(t, err)
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
resp, err := noRedirect(tt.name).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, tt.want, resp.StatusCode)
|
||||
switch tt.want {
|
||||
case http.StatusTemporaryRedirect:
|
||||
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||
case http.StatusInternalServerError:
|
||||
body := map[string]string{}
|
||||
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||
assert.Equal(t, twirpInternal, body["code"])
|
||||
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||
// 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) {
|
||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(list, wanted, 0o600)
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -220,14 +221,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
||||
downloads(router, artifactPath)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||
Addr: net.JoinHostPort(addr, port),
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// run server
|
||||
go func() {
|
||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
||||
logger.Infof("Start server on http://%s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
|
||||
+21
-85
@@ -325,33 +325,6 @@ 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 {
|
||||
@@ -373,18 +346,21 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
|
||||
isOfflineMode := input.OfflineMode
|
||||
|
||||
// fetch latest changes
|
||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||
|
||||
if input.InsecureSkipTLS { // For Gitea
|
||||
fetchOptions.InsecureSkipTLS = true
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
shallow := isShallow(r)
|
||||
if shallow {
|
||||
if isShallow(r) {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
@@ -392,7 +368,9 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
|
||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||
_, _, present := refRevision(r, input.Ref)
|
||||
// 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 {
|
||||
@@ -415,7 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
rev, refType, _ := refRevision(r, input.Ref)
|
||||
rev, _ = refRevision(r, input.Ref)
|
||||
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
@@ -427,47 +405,13 @@ 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 := ""
|
||||
|
||||
switch {
|
||||
case refresh && !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:
|
||||
if 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,
|
||||
@@ -476,14 +420,6 @@ 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
|
||||
}
|
||||
@@ -562,22 +498,22 @@ func pinnedRefSpec(sha string) config.RefSpec {
|
||||
}
|
||||
|
||||
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
|
||||
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), "sha", err == nil
|
||||
return plumbing.Revision(ref), err == nil
|
||||
}
|
||||
if _, err := r.Tag(ref); err == nil {
|
||||
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
|
||||
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), "branch", true
|
||||
return plumbing.Revision(remoteRef), true
|
||||
}
|
||||
rev := plumbing.Revision(ref)
|
||||
_, err := r.ResolveRevision(rev)
|
||||
return rev, "sha", err == nil
|
||||
return rev, err == nil
|
||||
}
|
||||
|
||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||
|
||||
+34
-23
@@ -6,7 +6,6 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -524,30 +523,51 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
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, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "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 tag": {"v1", 0},
|
||||
"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)
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
|
||||
})(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Load())
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -736,12 +756,3 @@ 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,6 +9,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -77,13 +79,20 @@ 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
|
||||
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)
|
||||
}
|
||||
|
||||
// Close removes what the job created through the socket, then stops serving it.
|
||||
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||
return p.close(ctx)
|
||||
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
|
||||
return p.closeErr
|
||||
}
|
||||
|
||||
// Info is a snapshot of a container, as of one inspect.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -172,3 +173,47 @@ 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,16 +6,50 @@ 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")))
|
||||
|
||||
+421
-115
File diff suppressed because it is too large.
Load diff
@@ -10,17 +10,23 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
@@ -56,7 +62,12 @@ func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -64,16 +75,33 @@ func TestDockerProxy(t *testing.T) {
|
||||
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 strings.HasSuffix(r.URL.Path, "/start"):
|
||||
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()
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n"))
|
||||
_, _ = io.Copy(conn, buffered)
|
||||
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))
|
||||
@@ -83,34 +111,94 @@ func TestDockerProxy(t *testing.T) {
|
||||
t.Cleanup(func() { _ = daemon.Close() })
|
||||
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
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 path, body := range map[string]string{
|
||||
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`,
|
||||
"/networks/create": `{"Name":"n"}`,
|
||||
"/volumes/create": `{"Name":"v","labels":null}`,
|
||||
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"+path, "application/json", strings.NewReader(body))
|
||||
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(<-bodies, &got))
|
||||
key := "Labels"
|
||||
if _, ok := got["labels"]; ok {
|
||||
key = "labels"
|
||||
}
|
||||
var labels map[string]string
|
||||
require.NoError(t, json.Unmarshal(got[key], &labels))
|
||||
assert.Equal(t, "job-1", labels[jobLabel], path)
|
||||
if strings.Contains(body, "own") {
|
||||
assert.Equal(t, "1", labels["own"])
|
||||
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
|
||||
}
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -123,40 +211,102 @@ func TestDockerProxy(t *testing.T) {
|
||||
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
||||
})
|
||||
|
||||
t.Run("tunnels raw streams", func(t *testing.T) {
|
||||
conn, err := net.Dial("unix", proxy.Socket)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n"))
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
resp, err := http.ReadResponse(reader, nil)
|
||||
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)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type"))
|
||||
echoed, err := reader.ReadString('\n')
|
||||
<-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)
|
||||
assert.Equal(t, "{}ping\n", echoed)
|
||||
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)
|
||||
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
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{}, nil)
|
||||
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
|
||||
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
|
||||
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
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)
|
||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil)
|
||||
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
|
||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
||||
|
||||
require.NoError(t, removeLabelled(ctx, cli, "job-1"))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -168,13 +318,14 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer direct.Close()
|
||||
dir := shortTempDir(t)
|
||||
seen, err := daemonSeesDir(ctx, dir)
|
||||
seen, err := daemonSeesDir(ctx, direct, dir, dir)
|
||||
require.NoError(t, err)
|
||||
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||
|
||||
job := "proxy-test-" + t.Name()
|
||||
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()
|
||||
@@ -212,6 +363,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
||||
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{})
|
||||
@@ -268,3 +420,79 @@ func BenchmarkDockerProxy(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type probeClient struct {
|
||||
mobyclient.APIClient
|
||||
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||
}
|
||||
|
||||
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
|
||||
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
|
||||
}
|
||||
|
||||
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||
return c.create(opts)
|
||||
}
|
||||
|
||||
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||
return c.remove(ctx, id, opts)
|
||||
}
|
||||
|
||||
func TestDaemonSeesDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
markers := make(map[string]bool)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
private bool
|
||||
removeErr error
|
||||
}{
|
||||
{name: "cleanup after cancellation"},
|
||||
{name: "private filesystem with stale marker", private: true},
|
||||
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
daemonDir := dir
|
||||
if testCase.private {
|
||||
daemonDir = t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
|
||||
}
|
||||
removed := false
|
||||
cli := &probeClient{
|
||||
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||
require.Len(t, opts.HostConfig.Mounts, 1)
|
||||
marker := opts.HostConfig.Mounts[0].Source
|
||||
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
|
||||
assert.False(t, markers[marker])
|
||||
markers[marker] = true
|
||||
info, err := os.Stat(marker)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.Mode().IsRegular())
|
||||
cancel()
|
||||
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
|
||||
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
||||
}
|
||||
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||
},
|
||||
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||
removed = true
|
||||
assert.Equal(t, "probe", id)
|
||||
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
|
||||
require.NoError(t, ctx.Err())
|
||||
_, bounded := ctx.Deadline()
|
||||
assert.True(t, bounded)
|
||||
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
||||
},
|
||||
}
|
||||
seen, err := daemonSeesDir(ctx, cli, dir, dir)
|
||||
require.ErrorIs(t, err, testCase.removeErr)
|
||||
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
|
||||
assert.Equal(t, !testCase.private, removed)
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("docker socket ownership is unavailable")
|
||||
}
|
||||
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
|
||||
return groupErr
|
||||
}
|
||||
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := uint16(info.Mode().Perm())
|
||||
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
|
||||
return err
|
||||
}
|
||||
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
|
||||
return groupErr
|
||||
}
|
||||
owner, group, other := mode>>6, mode>>3&7, mode&7
|
||||
acl := binary.LittleEndian.AppendUint32(nil, 2)
|
||||
for _, entry := range []struct {
|
||||
tag, perm uint16
|
||||
id uint32
|
||||
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
|
||||
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
|
||||
}
|
||||
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||
}
|
||||
+39
-25
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -257,6 +258,27 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
||||
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 {
|
||||
info.State = string(state.Status)
|
||||
@@ -488,14 +510,11 @@ func (cr *containerReference) remove() common.Executor {
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
logger.Debugf("Removed container: %v", idOrName)
|
||||
@@ -504,7 +523,7 @@ func (cr *containerReference) remove() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
|
||||
// per container, against the one minute the post-job executor allows for the whole
|
||||
// cleanup, so a job with several services can spend most of that budget here
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
@@ -514,18 +533,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
||||
Condition: container.WaitConditionRemoved,
|
||||
})
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
case <-ctx.Done():
|
||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
||||
// behind or its goroutine parks on the send for the lifetime of the process
|
||||
go func() {
|
||||
select {
|
||||
case <-waitResult.Result:
|
||||
case <-waitResult.Error:
|
||||
}
|
||||
}()
|
||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
||||
case result := <-waitResult.Result:
|
||||
if result.Error != nil {
|
||||
return errors.New(result.Error.Message)
|
||||
}
|
||||
return nil
|
||||
case err := <-waitResult.Error:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,15 +1291,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []glob.Glob
|
||||
host []glob.Glob
|
||||
named []*glob.Pattern
|
||||
host []*glob.Pattern
|
||||
}
|
||||
|
||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||
logger := common.Logger(ctx)
|
||||
ret := validVolumeMatcher{
|
||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
||||
named: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||
host: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||
}
|
||||
|
||||
for _, v := range validVolumes {
|
||||
|
||||
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.T) {
|
||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||
// 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
|
||||
wantFailure bool
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
waitErr error
|
||||
wantErr error
|
||||
}{
|
||||
{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: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
@@ -366,21 +370,24 @@ 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)
|
||||
removed <- container.WaitResponse{}
|
||||
waitErrors := make(chan error, 1)
|
||||
if tc.waitErr == nil {
|
||||
removed <- container.WaitResponse{}
|
||||
} else {
|
||||
waitErrors <- tc.waitErr
|
||||
}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
||||
if tc.wantErr != nil {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -807,12 +814,23 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
||||
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",
|
||||
"/var/run/docker.sock": "/var/run/docker.sock",
|
||||
"/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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package container
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,10 @@ 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) {
|
||||
@@ -29,8 +34,12 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
func DockerProxyDir(ctx context.Context) string {
|
||||
return ""
|
||||
func RemoveDockerJobResources(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
|
||||
return nil
|
||||
}
|
||||
|
||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||
@@ -85,3 +94,11 @@ 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,40 +8,67 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
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 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 removeExecutor(volume string, force bool) common.Executor {
|
||||
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 {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("docker volume rm %s", volume)
|
||||
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ 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.
|
||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
||||
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||
}
|
||||
|
||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||
|
||||
@@ -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{MaxParallel: 3}
|
||||
strategy := &model.Strategy{MaxParallelString: "3"}
|
||||
parent := &RunContext{
|
||||
Config: &Config{},
|
||||
Matrix: map[string]any{"os": "linux"},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDockerProxyMountedJob(t *testing.T) {
|
||||
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
|
||||
if mode == "" {
|
||||
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
|
||||
}
|
||||
require.Contains(t, []string{"proxy", "direct"}, mode)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
dockerClient, err := container.GetDockerClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
|
||||
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
|
||||
|
||||
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
|
||||
require.NoError(t, err)
|
||||
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
|
||||
runner, err := New(&Config{
|
||||
Workdir: fixtureDir,
|
||||
ActionCacheDir: t.TempDir(),
|
||||
EventName: "push",
|
||||
PlatformPicker: mapPlatformPicker(platforms),
|
||||
ContainerNamePrefix: resourceName,
|
||||
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||
ContainerMaxLifetime: 2 * time.Minute,
|
||||
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
||||
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
||||
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
||||
require.NoError(t, err)
|
||||
plan, err := planner.PlanEvent("push")
|
||||
if mode == "direct" {
|
||||
plan, err = planner.PlanJob("proxy")
|
||||
}
|
||||
require.NoError(t, err)
|
||||
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
||||
require.NoError(t, err)
|
||||
jobName := runContext.jobContainerName()
|
||||
jobNetwork, _ := runContext.networkNameForGitea()
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cleanCancel()
|
||||
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
for _, name := range []string{resourceName, jobNetwork} {
|
||||
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
|
||||
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
hook := &test.Hook{}
|
||||
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
||||
var messages []string
|
||||
for _, entry := range hook.AllEntries() {
|
||||
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||
}
|
||||
require.Contains(t, messages, "docker proxy post verified")
|
||||
if mode == "proxy" {
|
||||
require.Contains(t, messages, "docker binds verified")
|
||||
}
|
||||
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
|
||||
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
||||
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
||||
if mode == "proxy" {
|
||||
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
|
||||
if mode == "proxy" {
|
||||
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,29 @@ 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())
|
||||
@@ -41,8 +64,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
if rc.Run != nil {
|
||||
job := rc.Run.Job()
|
||||
if job != nil && job.Strategy != nil {
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
setStrategyContext(strategy, job.Strategy)
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
@@ -118,8 +140,7 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
||||
job := rc.Run.Job()
|
||||
strategy := make(map[string]any)
|
||||
if job.Strategy != nil {
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
setStrategyContext(strategy, job.Strategy)
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
@@ -333,12 +354,16 @@ 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 := callerJob.With[name], callerEval, "input"
|
||||
value, eval, label := callerWith[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, ""},
|
||||
{" 1 ", 1.0, ""},
|
||||
// {"1 + 3", "4", ""},
|
||||
// {"(1 + 3) * -2", "-8", ""},
|
||||
{"'my text'", "my text", ""},
|
||||
@@ -266,7 +266,6 @@ func TestInterpolate(t *testing.T) {
|
||||
{"${{ null }}", ""},
|
||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||
{"${{ 1", "${{ 1"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
@@ -277,7 +276,7 @@ func TestInterpolate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
|
||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||
_, err := ee.Interpolate(context.Background(), in)
|
||||
assert.Error(t, err, in)
|
||||
}
|
||||
@@ -360,6 +359,32 @@ 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,18 +263,19 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
return err
|
||||
})
|
||||
|
||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||
|
||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||
Finally(func(ctx context.Context) error {
|
||||
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 {
|
||||
// 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)
|
||||
}).
|
||||
Finally(info.closeContainer()))
|
||||
})(ctx)
|
||||
}).Finally(info.closeContainer())
|
||||
}
|
||||
|
||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||
@@ -416,6 +417,14 @@ 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,6 +300,8 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
result string
|
||||
hasError bool
|
||||
output string
|
||||
startError error
|
||||
cancelOnStart bool
|
||||
}{
|
||||
{
|
||||
name: "zeroSteps",
|
||||
@@ -435,6 +437,21 @@ 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 {
|
||||
@@ -445,7 +462,8 @@ 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 := common.WithJobErrorContainer(context.Background())
|
||||
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
defer cancel()
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
@@ -470,9 +488,12 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
jim.On("steps").Return(tt.steps)
|
||||
|
||||
if len(tt.steps) > 0 {
|
||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
||||
jim.On("startContainer").Return(func(_ context.Context) error {
|
||||
executorOrder = append(executorOrder, "startContainer")
|
||||
return nil
|
||||
if tt.cancelOnStart {
|
||||
cancel()
|
||||
}
|
||||
return tt.startError
|
||||
})
|
||||
}
|
||||
|
||||
@@ -506,7 +527,7 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
defer sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
if len(tt.steps) > 0 {
|
||||
if len(tt.steps) > 0 && tt.startError == nil {
|
||||
jim.On("matrix").Return(map[string]any{})
|
||||
|
||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||
@@ -520,12 +541,17 @@ 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
|
||||
@@ -534,7 +560,14 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
|
||||
executor := newJobExecutor(jim, sfm, rc)
|
||||
err := executor(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
switch {
|
||||
case tt.startError != nil:
|
||||
require.ErrorIs(t, err, tt.startError)
|
||||
case tt.cancelOnStart:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
default:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -34,9 +35,14 @@ func TestMaxParallelStrategy(t *testing.T) {
|
||||
expectedMaxParallel: 4,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-10",
|
||||
name: "max-parallel-10-clamped-to-combinations",
|
||||
maxParallelString: "10",
|
||||
expectedMaxParallel: 10,
|
||||
expectedMaxParallel: 5,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-invalid-falls-back-to-default",
|
||||
maxParallelString: "tow",
|
||||
expectedMaxParallel: 4,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,9 +67,29 @@ func TestMaxParallelStrategy(t *testing.T) {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, matrixes)
|
||||
assert.Len(t, matrixes, 5)
|
||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
||||
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("deferred strategy", func(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
jobs:
|
||||
test:
|
||||
if: false
|
||||
strategy: ${{ fromJSON('{"max-parallel":2,"fail-fast":false,"matrix":{"os":["ubuntu","windows"]}}') }}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
job := workflow.Jobs["test"]
|
||||
rawStrategy := model.CloneYamlNode(job.RawStrategy)
|
||||
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||
require.NotNil(t, job.Strategy)
|
||||
assert.False(t, job.Strategy.GetFailFast())
|
||||
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
|
||||
matrixes, err := job.GetMatrixes()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []map[string]any{{"os": "ubuntu"}, {"os": "windows"}}, matrixes)
|
||||
assert.Equal(t, rawStrategy, job.RawStrategy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
||||
@@ -81,4 +107,15 @@ 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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
+24
-16
@@ -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, which is what
|
||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||
// 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.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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{}
|
||||
for _, script := range scripts {
|
||||
switch patched, err := patchBundle(script); {
|
||||
case err != nil:
|
||||
original, err := patchBundle(script)
|
||||
if original != nil {
|
||||
restore[script] = original // also when the write failed part way through
|
||||
}
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||
case patched:
|
||||
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
for script, original := range restore {
|
||||
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
|
||||
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func patchBundle(script string) (bool, error) {
|
||||
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||
func patchBundle(script string) ([]byte, error) {
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return false, nil
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return false, nil
|
||||
return nil, nil
|
||||
}
|
||||
// No atomic write needed: every prepare checks the action out and hard resets it.
|
||||
return true, os.WriteFile(script, patched, info.Mode().Perm())
|
||||
return data, 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)
|
||||
|
||||
done, err := patchBundle(script)
|
||||
original, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, done)
|
||||
require.NotNil(t, original)
|
||||
patched, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gateOpened(string(patched)))
|
||||
|
||||
done, err = patchBundle(script)
|
||||
original, err = patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, done, "a patched bundle is not patched again")
|
||||
assert.Nil(t, original, "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")`)
|
||||
|
||||
done, err := patchBundle(script)
|
||||
original, err := patchBundle(script)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, done)
|
||||
assert.Nil(t, original)
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||
@@ -251,6 +251,9 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+143
-88
@@ -16,7 +16,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
maps0 "maps"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
@@ -67,6 +69,7 @@ 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
|
||||
@@ -97,6 +100,7 @@ type RunContext struct {
|
||||
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.
|
||||
@@ -252,7 +256,7 @@ func (rc *RunContext) validVolumes() []string {
|
||||
volumes = append(volumes, sharedToolCacheVolume)
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
volumes = append(volumes, rc.Config.Workdir)
|
||||
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
volumes = append(volumes, rc.dockerProxy.Socket)
|
||||
@@ -262,6 +266,13 @@ func (rc *RunContext) validVolumes() []string {
|
||||
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
|
||||
@@ -270,20 +281,10 @@ func (rc *RunContext) jobDockerSocket() string {
|
||||
}
|
||||
|
||||
func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
||||
daemonSocket := rc.containerDaemonSocket()
|
||||
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
|
||||
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
|
||||
return
|
||||
}
|
||||
dir := container.DockerProxyDir(ctx)
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||
return
|
||||
}
|
||||
rc.dockerProxy = proxy
|
||||
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
||||
}
|
||||
|
||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||
@@ -366,6 +367,11 @@ 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" {
|
||||
@@ -374,9 +380,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
||||
if selinux.GetEnabled() {
|
||||
bindModifiers = ":z"
|
||||
}
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
|
||||
} else {
|
||||
mounts[name] = workdir
|
||||
mounts[name] = target
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,8 +474,15 @@ 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)
|
||||
@@ -483,26 +496,23 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||
// We need to set the container name to the environment variable.
|
||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||
rc.startDockerProxy(ctx)
|
||||
|
||||
envList := make([]string, 0)
|
||||
|
||||
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)
|
||||
|
||||
// add service containers
|
||||
for serviceID, spec := range rc.Run.Job().Services {
|
||||
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 {
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||
@@ -590,8 +600,6 @@ 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
|
||||
|
||||
@@ -599,7 +607,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
containerInput := &container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||
@@ -608,10 +617,8 @@ 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,
|
||||
@@ -620,25 +627,34 @@ 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(),
|
||||
rc.waitForServiceContainers(),
|
||||
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
rc.captureJobContainerInfo(),
|
||||
@@ -651,6 +667,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Mode: 0o666,
|
||||
Body: "",
|
||||
}),
|
||||
rc.waitForServiceContainers(),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
@@ -663,47 +680,69 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
removeJobContainer := rc.JobContainer != nil
|
||||
|
||||
var errs []error
|
||||
if removeJobContainer {
|
||||
errs := []error{rc.closeDockerProxy(ctx)}
|
||||
if rc.JobContainer != nil {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
}
|
||||
if len(rc.serviceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
errs = append(errs, rc.stopServiceContainers()(ctx))
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
if err := rc.dockerProxy.Close(ctx); err != nil {
|
||||
logger.Errorf("Error while removing what the job created: %v", err)
|
||||
}
|
||||
rc.dockerProxy = nil
|
||||
if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
|
||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
||||
}
|
||||
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 preclean || rc.deferVolumeCleanup == nil {
|
||||
errs = append(errs, rc.cleanupJobVolumes(ctx))
|
||||
} else {
|
||||
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// last, once every container has detached
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
||||
}
|
||||
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()
|
||||
@@ -763,7 +802,6 @@ 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 {
|
||||
@@ -788,7 +826,6 @@ 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),
|
||||
))
|
||||
@@ -800,19 +837,23 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
||||
func (rc *RunContext) stopServiceContainers() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
|
||||
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
|
||||
})
|
||||
}
|
||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||
errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServiceReadyTimeout = 5 * time.Minute
|
||||
serviceReadyPollMax = 32 * time.Second
|
||||
)
|
||||
const defaultServiceReadyTimeout = 5 * time.Minute
|
||||
|
||||
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait
|
||||
const serviceReadyPollInterval = time.Second
|
||||
|
||||
// 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.
|
||||
@@ -872,7 +913,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)
|
||||
interval := serviceReadyPollInterval
|
||||
loggedStarting := false
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
@@ -902,12 +943,14 @@ func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds()))
|
||||
if !loggedStarting {
|
||||
rawLogger.Infof("%s service is starting.", svc.name)
|
||||
loggedStarting = true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done(): // reported at the top of the loop
|
||||
case <-time.After(interval):
|
||||
case <-time.After(serviceReadyPollInterval):
|
||||
}
|
||||
interval = min(interval*2, serviceReadyPollMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,8 +992,15 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||
return nil
|
||||
}
|
||||
rc.jobContainerID = info.ID
|
||||
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
|
||||
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
|
||||
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
|
||||
}
|
||||
@@ -995,6 +1045,15 @@ 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 {
|
||||
@@ -1033,17 +1092,13 @@ func (rc *RunContext) startContainer() common.Executor {
|
||||
}
|
||||
|
||||
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
||||
if rc.cleanUpJobContainer == nil {
|
||||
return
|
||||
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
cleanup := rc.cleanUpJobContainer
|
||||
if cleanup == nil {
|
||||
cleanup = rc.closeDockerProxy
|
||||
}
|
||||
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 {
|
||||
if err := cleanup(cleanCtx); err != nil {
|
||||
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
|
||||
}
|
||||
}
|
||||
@@ -1655,7 +1710,7 @@ func imageOSFromImage(image string) string {
|
||||
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
|
||||
if actionsRuntimeURL == "" {
|
||||
actionsRuntimeURL = fmt.Sprintf("http://%s:%s/", rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)
|
||||
actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/"
|
||||
}
|
||||
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
|
||||
|
||||
|
||||
+339
-83
File diff suppressed because it is too large.
Load diff
+28
-25
@@ -19,6 +19,7 @@ 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
|
||||
@@ -155,6 +156,18 @@ 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
|
||||
@@ -191,21 +204,24 @@ 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 {
|
||||
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)
|
||||
|
||||
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||
if err != nil {
|
||||
return 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,20 +231,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
}
|
||||
log.Debugf("Job Matrices: %v", 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)
|
||||
}
|
||||
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
||||
|
||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type step interface {
|
||||
@@ -261,6 +262,14 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -205,6 +206,55 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
name: docker-proxy
|
||||
description: Verify mounted Docker access through post steps
|
||||
runs:
|
||||
using: node24
|
||||
main: index.js
|
||||
post: index.js
|
||||
@@ -0,0 +1,53 @@
|
||||
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
@@ -0,0 +1,56 @@
|
||||
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"
|
||||
@@ -28,3 +28,24 @@ 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,6 +27,7 @@ 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 = fmt.Sprintf("http://%s:3000", name)
|
||||
baseURL = "http://" + net.JoinHostPort(name, "3000")
|
||||
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 = fmt.Sprintf("http://%s:%d", host, port)
|
||||
baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(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})
|
||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
return errors.Join(removeErr, f.cli.Close())
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type runnerOptions struct {
|
||||
capacity int
|
||||
ephemeral bool
|
||||
cacheV2 *bool
|
||||
cacheHost string
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||
@@ -49,6 +50,7 @@ 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
@@ -0,0 +1,28 @@
|
||||
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
|
||||
Loaded 50 of 66 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user