Compare commits

..
4 Commits
Author SHA1 Message Date
silverwindandbircni 498282caaa fix: IPv6 URLs, cache-server SIGTERM and atomic task admission (#1217)
Three independent fixes, one commit each.

1. Hosts and ports were interpolated directly when building URLs, so a literal IPv6 address produced an unbracketed authority, making `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and the artifact server listener unusable. Hosts are expected bare, as documented for `cache.host`, so an address that already carries brackets is no longer accepted.
2. `cache-server` waited on its own `os.Interrupt` channel and ignored SIGTERM, so service managers and container runtimes had to kill it.
3. Task admission used a separate `Load` and `Store`, so two concurrent dispatches of the same task id could both be admitted.

Assisted-by: Claude Code:Opus 5
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1217
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-09 15:58:09 +00:00
silverwind 2ed8cdb76e fix: stop artifact uploads depending on the cache server reaching Gitea (#1216)
Cache v2 makes the cache server the `ACTIONS_RESULTS_URL` origin, so artifact calls arrived there and were proxied on to Gitea, failing whenever it could not reach the instance.

- Artifact calls are answered with a redirect, so the cache server opens no connection to Gitea. A scheme change or an untrusted instance is still proxied, but there the cache server is the runner itself, which already reaches Gitea.
- Failures answer in twirp, not an empty `502` that clients report as `Unexpected end of JSON input`.
- `cache.v2: false` really points artifacts at Gitea now.
- Cache reservations are bound to the job that made them, so two jobs saving one key cannot commit against each other's upload, and a retry after a lost answer no longer fails a saved entry.
- The toolkit patch, which edits the GitHub-host check out of an action's bundle, was left in the shared checkout where a job running with `runner.patch_actions: false` could inherit it. It is put back after the job's copy.
- `exec` names an origin for the cache v2 it advertises, and masks its runtime token.

Behaviour changes: `no_proxy` no longer exempts `cache.external_server`, and `cache.enabled: false` also stops external registration.

Fixes https://gitea.com/gitea/runner/issues/1208
Fixes https://gitea.com/gitea/runner/issues/1211

Assisted by Claude (Opus 5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1216
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-08 18:45:10 +00:00
Zettat123andsilverwind ff9965e940 fix: stop the job's docker socket from becoming a directory (#1215)
Fixes https://gitea.com/gitea/runner/issues/1213

Fix the DooD regression that mounts `/var/run/docker.sock` as a directory. Keep the Docker proxy available through job and post steps. Clean stale resources before opening it, then remove containers before their networks and volumes during teardown.

Use a unique filesystem probe and preserve socket ownership. Fall back to direct access when proxying is unsupported. Preserve exec output and clean up active streams and failed starts.

Add a real Docker job test for mounted socket access, post steps and resource cleanup.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-08 04:16:02 +00:00
Renovate Botandsilverwind ba4d3c5b4f chore(deps): update dependencies and tweak make checks (#1212)
1. Dependency updates: gobwas/glob v1.0.0, which replaced its `Glob` interface with `*Pattern`, logrus v1.10.2, prometheus/client_model v0.6.3, golangci-lint v2.13.2, Go 1.27.1, docker/setup-qemu-action and nginx digests.
2. `make fmt-check` and `make tidy-check` report only what the formatter or `go mod tidy` would change, so unrelated uncommitted work no longer fails `make checks`.

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1212
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-07 08:20:15 +00:00
49 changed files with 1510 additions and 423 deletions

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
run: | run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for attempt in 1 2 3; do for attempt in 1 2 3; do
+5 -15
View File
@@ -18,7 +18,7 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8 GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -96,13 +96,8 @@ go-check:
fi fi
.PHONY: fmt-check .PHONY: fmt-check
fmt-check: fmt fmt-check: ## check the Go code formatting
@diff=$$(git diff --color=always -- '*.go'); \ $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: deps-tools .PHONY: deps-tools
deps-tools: ## install tool dependencies deps-tools: ## install tool dependencies
@@ -149,13 +144,8 @@ tidy: ## run go mod tidy
fi fi
.PHONY: tidy-check .PHONY: tidy-check
tidy-check: tidy tidy-check: ## check that go.mod and go.sum are tidy
@diff=$$(git diff --color=always -- go.mod go.sum); \ $(GO) mod tidy -diff
if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: test .PHONY: test
test: ## test everything (integration tests self-skip without docker/network) test: ## test everything (integration tests self-skip without docker/network)
+5 -3
View File
@@ -275,7 +275,7 @@ volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data - ${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 #### 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: These hosts are added to `no_proxy` for jobs, so they are always reached directly:
- the cache server - the built-in cache server, whose address is assigned at startup
- `localhost`, `127.0.0.1` and `::1` - `localhost`, `127.0.0.1` and `::1`
- the job's service containers - the job's service containers
- the Docker daemon, when it is reached over `tcp://` - the Docker daemon, when it is reached over `tcp://`
@@ -325,7 +325,9 @@ cache:
v2: false 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. Those actions refuse any host they do not take for GitHub, so the runner edits that check out of the bundle on its way into the job and puts the shared copy back afterwards. A bundle it does not recognise is left alone. 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. Set `runner.patch_actions: false` to leave bundles as shipped; the artifact actions then refuse and the cache client keeps to v1.
With v2 the job's artifact calls go via the cache server, so jobs need to reach it to upload artifacts, not just to cache. `v2: false` sends them to Gitea directly.
**Shared cache across multiple runners** **Shared cache across multiple runners**
+19 -5
View File
@@ -234,7 +234,7 @@ func StartHandler(opts Options) (*Handler, error) {
func (h *Handler) ExternalURL() string { func (h *Handler) ExternalURL() string {
// TODO: make the external url configurable if necessary // TODO: make the external url configurable if necessary
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port) return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
} }
func (h *Handler) baseURL(cred JobCredential) string { func (h *Handler) baseURL(cred JobCredential) string {
@@ -603,12 +603,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
h.logger.Debugf("%s %s", r.Method, r.URL.Path) h.logger.Debugf("%s %s", r.Method, r.URL.Path)
token := bearerToken(r) token := bearerToken(r)
if token == "" { if token == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token")) h.unauthorized(w, r, errors.New("missing bearer token"))
return return
} }
cred, ok := h.lookupCredential(token) cred, ok := h.lookupCredential(token)
if !ok { if !ok {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token")) h.unauthorized(w, r, errors.New("unknown bearer token"))
return return
} }
ctx := context.WithValue(r.Context(), credKey{}, cred) ctx := context.WithValue(r.Context(), credKey{}, cred)
@@ -617,6 +617,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
} }
} }
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
h.twirpError(w, r, twirpUnauthenticated, err)
return
}
h.responseJSON(w, r, http.StatusUnauthorized, err)
}
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the // signedAuth authenticates a signed URL. purpose separates the flavours of URL the
// handler hands out, so one cannot be replayed as another; see computeSignature. // handler hands out, so one cannot be replayed as another; see computeSignature.
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle { func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
@@ -720,6 +728,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
h.responseJSON(w, r, http.StatusOK) h.responseJSON(w, r, http.StatusOK)
} }
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
func hashedToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func bearerToken(r *http.Request) string { func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
const prefix = "Bearer " const prefix = "Bearer "
@@ -805,8 +819,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
} }
cache := &Cache{} cache := &Cache{}
err := db.FindOne(cache, err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo). bolthold.Where("Key").Eq(key).Index("Key").
And("Key").Eq(key). And("Repo").Eq(repo).
And("Version").Eq(version). And("Version").Eq(version).
And("Complete").Eq(complete). And("Complete").Eq(complete).
SortBy(sortBy).Reverse()) SortBy(sortBy).Reverse())
+4
View File
@@ -52,6 +52,10 @@ func signArtifactURL(h *Handler, id int64) string {
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL)) return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
} }
func TestHandler_ExternalURL(t *testing.T) {
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
}
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache") dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{Dir: dir}) handler, err := StartHandler(Options{Dir: dir})
+43 -12
View File
@@ -39,8 +39,8 @@ const (
blobUploadURLTTL = time.Hour blobUploadURLTTL = time.Hour
// twirpInternal is the only error code that is not the client's fault. twirpInternal = "internal"
twirpInternal = "internal" twirpUnauthenticated = "unauthenticated"
) )
func (h *Handler) registerV2Routes(router *httprouter.Router) { func (h *Handler) registerV2Routes(router *httprouter.Router) {
@@ -56,7 +56,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2CreateRequest](r) req, err := decodeTwirpRequest[v2CreateRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
if req.Key == "" || req.Version == "" { if req.Key == "" || req.Version == "" {
@@ -83,11 +83,30 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
return return
} }
// A second live reservation is finalized by whichever job calls last, against the other's upload.
owner := hashedToken(bearerToken(r))
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
if pending.Owner != owner {
h.twirpNotOK(w, r)
return
}
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
"ok": true,
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
})
return
}
now := time.Now().Unix() now := time.Now().Unix()
cache := &Cache{ cache := &Cache{
Repo: cred.Repo, Repo: cred.Repo,
Key: req.Key, Key: req.Key,
Version: req.Version, Version: req.Version,
Owner: owner,
Size: -1, // the size is only known at finalize time Size: -1, // the size is only known at finalize time
CreatedAt: now, CreatedAt: now,
UsedAt: now, UsedAt: now,
@@ -107,7 +126,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2FinalizeRequest](r) req, err := decodeTwirpRequest[v2FinalizeRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
@@ -119,6 +138,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
defer db.Close() defer db.Close()
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false) cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
cache = nil // not the reservation this job made, so not this job's to commit
}
if err == nil && cache == nil {
// A retry whose first response was lost finds the entry already committed.
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
}
if err != nil { if err != nil {
h.twirpError(w, r, twirpInternal, err) h.twirpError(w, r, twirpInternal, err)
return return
@@ -127,13 +153,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
h.twirpNotOK(w, r) h.twirpNotOK(w, r)
return return
} }
db.Close() // commitCache needs the store closed
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel)) if !cache.Complete {
if err := h.commitCache(cache); err != nil { db.Close() // commitCache needs the store closed
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err) cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
h.twirpNotOK(w, r) if err := h.commitCache(cache); err != nil {
return 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{ 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()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2DownloadRequest](r) req, err := decodeTwirpRequest[v2DownloadRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
@@ -230,8 +258,11 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) { func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err) h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
status := http.StatusBadRequest status := http.StatusBadRequest
if code == twirpInternal { switch code {
case twirpInternal:
status = http.StatusInternalServerError status = http.StatusInternalServerError
case twirpUnauthenticated:
status = http.StatusUnauthorized
} }
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()}) h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
} }
+110
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
} }
list.WriteString(`</BlockList>`) list.WriteString(`</BlockList>`)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes())) require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
"a client that lost the first answer retries the list it already sent")
var reordered bytes.Buffer
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
for _, blockID := range []string{order[1], order[0], order[2]} {
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
}
reordered.WriteString(`</BlockList>`)
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
"the blocks are already assembled, so a different order would silently disagree with them")
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{ finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"), "key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, got["ok"]) assert.Equal(t, false, got["ok"])
}) })
} }
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
handler := newTestHandler(t, Policy{})
tests := []struct {
name string
token string
body string
status int
code string
}{
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
require.NoError(t, err)
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.status, resp.StatusCode)
got := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, tt.code, got["code"])
assert.NotEmpty(t, got["msg"])
})
}
}
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
handler := newTestHandler(t, Policy{})
content := []byte("the cached archive")
const otherJob = "another-jobs-token"
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
reserve := func(client *http.Client) map[string]any {
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
}
first := reserve(testClient)
require.Equal(t, true, first["ok"])
retry := reserve(testClient)
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
entryOf := func(reserved map[string]any) string {
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
require.NoError(t, err)
return parsed.Path
}
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
"another job's reservation would be finalized against the first upload")
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
require.Equal(t, true, reserved["ok"])
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"another job's upload is not this job's to finalize")
base := handler.ExternalURL() + apiPath
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
require.NoError(t, err)
var madeByV1 struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
resp.Body.Close()
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
require.NoError(t, err)
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err = testClient.Do(upload)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"a reservation this job did not make through v2 is not its to commit")
finalized, _ := saveV2(t, handler, "deps", "v1", content)
require.Equal(t, true, finalized["ok"])
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
})
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
assert.Equal(t, finalized["entry_id"], again["entry_id"])
}
+1
View File
@@ -16,6 +16,7 @@ type Cache struct {
Key string `json:"key" boltholdIndex:"Key"` Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"` Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"` Size int64 `json:"cacheSize"`
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
Complete bool `json:"complete" boltholdIndex:"Complete"` Complete bool `json:"complete" boltholdIndex:"Complete"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"` UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"` CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
+47 -5
View File
@@ -5,6 +5,8 @@ package artifactcache
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -23,15 +25,33 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job // forwardOrNotFound is the router's fallback: the artifact service of the instance the job
// registered with, and the 404 the router would have written otherwise. // registered with, and the 404 the router would have written otherwise.
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) { func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
cred, ok := h.lookupCredential(bearerToken(r)) if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
cred, ok := h.lookupCredential(bearerToken(r))
if !ok {
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
return
}
if cred.Results == "" {
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
return
}
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/")) target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
err = errors.New("not an absolute http or https URL the job could use")
}
if err != nil { if err != nil {
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err) h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
w.WriteHeader(http.StatusBadGateway) h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
return
}
if !h.mustProxy(r, cred, target) {
redirect := target.JoinPath(r.URL.EscapedPath())
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
return return
} }
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target) h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
@@ -44,9 +64,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
// directly and never through here. // directly and never through here.
r.Out.Host = target.Host r.Out.Host = target.Host
}, },
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Warnf("artifact service forward to %s: %v", target, err) h.logger.Warnf("artifact service forward to %s: %v", target, err)
w.WriteHeader(http.StatusBadGateway) h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
}, },
} }
if cred.InsecureTLS { if cred.InsecureTLS {
@@ -55,5 +75,27 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
} }
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
if cred.InsecureTLS && target.Scheme == "https" {
return true
}
scheme := r.Header.Get("X-Forwarded-Proto")
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
scheme = base.Scheme
}
if scheme == "" {
scheme = "http"
}
return scheme != target.Scheme
}
func mergeQuery(target, request string) string {
if target == "" || request == "" {
return target + request
}
return target + "&" + request
}
// insecureTransport is shared, because a transport per request would pool no connections. // insecureTransport is shared, because a transport per request would pool no connections.
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
+93 -15
View File
@@ -4,6 +4,8 @@
package artifactcache package artifactcache
import ( import (
"cmp"
"encoding/json/v2"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -14,8 +16,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
// still point at Gitea, and nothing else is proxied.
func TestFrontResultsService(t *testing.T) { func TestFrontResultsService(t *testing.T) {
var gotHost, gotPath, gotProto string var gotHost, gotPath, gotProto string
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
_, _ = io.WriteString(w, `{"ok":true}`) _, _ = io.WriteString(w, `{"ok":true}`)
})) }))
defer gitea.Close() defer gitea.Close()
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
defer selfSigned.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"}) handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err) require.NoError(t, err)
defer handler.Close() defer handler.Close()
const token = "forward-token" const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
client := &http.Client{Transport: &bearerTransport{token: token}} noRedirect := func(token string) *http.Client {
post := func(path string) int { return &http.Client{
Transport: &bearerTransport{token: token},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
post := func(path string) (int, map[string]string) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil) req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
require.NoError(t, err) require.NoError(t, err)
resp, err := client.Do(req) resp, err := noRedirect(token).Do(req)
require.NoError(t, err) require.NoError(t, err)
resp.Body.Close() defer resp.Body.Close()
return resp.StatusCode body := map[string]string{}
_ = json.UnmarshalRead(resp.Body, &body)
return resp.StatusCode, body
} }
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"), status, body := post(rpc)
"an unregistered token is forwarded nowhere") assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
assert.Equal(t, twirpUnauthenticated, body["code"])
assert.Contains(t, body["msg"], "bearer")
assert.NotEmpty(t, body["msg"])
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})() defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact")) status, _ = post(rpc)
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from") assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs") assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath) assert.Equal(t, rpc, gotPath)
gotPath = "" gotPath = ""
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do")) status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo")) assert.Equal(t, http.StatusNotFound, status)
status, _ = post("/api/v1/repos/owner/repo")
assert.Equal(t, http.StatusNotFound, status)
assert.Empty(t, gotPath, "only the artifact service is forwarded") assert.Empty(t, gotPath, "only the artifact service is forwarded")
tests := []struct {
name string
cred JobCredential
forwardedProto string
reqQuery string
want int
location string
wantMsg string
}{
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
{
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
},
{
name: "a trusted https instance is reached by the job itself",
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
},
{
name: "the job does not share this server's disregard for the certificate",
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
},
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
{
name: "a runner too old to send its public URL leaves the terminator to say so",
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
},
{
name: "a scheme the job could not use is refused before it is sent one",
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
{
name: "an address with no host is refused before it is sent one",
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer handler.RegisterJob(tt.name, tt.cred)()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
require.NoError(t, err)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
resp, err := noRedirect(tt.name).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.want, resp.StatusCode)
switch tt.want {
case http.StatusTemporaryRedirect:
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
case http.StatusInternalServerError:
body := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
assert.Equal(t, twirpInternal, body["code"])
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
}
})
}
} }
+15 -1
View File
@@ -5,8 +5,10 @@
package artifactcache package artifactcache
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json/v2"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
// rename pass is safe because a staged name always carries blockFilePrefix and a target name // rename pass is safe because a staged name always carries blockFilePrefix and a target name
// never does, so no rename can collide with a block not yet moved. // never does, so no rename can collide with a block not yet moved.
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error { func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
if err != nil {
return err
}
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
if recorded, err := os.ReadFile(list); err == nil {
// A retry repeats the list it already sent; another one would reorder what is now staged.
if !bytes.Equal(recorded, wanted) {
return fmt.Errorf("cache %d was already assembled from a different block list", id)
}
return nil
}
for i, blockID := range blockIDs { for i, blockID := range blockIDs {
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil { if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
return err return err
} }
} }
return nil return os.WriteFile(list, wanted, 0o600)
} }
func (s *Storage) Commit(id uint64, size int64) (int64, error) { func (s *Storage) Commit(id uint64, size int64) (int64, error) {
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -220,14 +221,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
downloads(router, artifactPath) downloads(router, artifactPath)
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%s", addr, port), Addr: net.JoinHostPort(addr, port),
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
Handler: router, Handler: router,
} }
// run server // run server
go func() { go func() {
logger.Infof("Start server on http://%s:%s", addr, port) logger.Infof("Start server on http://%s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(err) logger.Fatal(err)
} }
+7 -4
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"sync"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -77,13 +78,15 @@ var ErrContainerNotFound = errors.New("does not exist")
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime. // DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
type DockerProxy struct { type DockerProxy struct {
Socket string Socket string
close func(context.Context) error close func(context.Context) error
closeOnce sync.Once
closeErr error
} }
// Close removes what the job created through the socket, then stops serving it.
func (p *DockerProxy) Close(ctx context.Context) error { 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. // Info is a snapshot of a container, as of one inspect.
+229 -110
View File
@@ -6,20 +6,22 @@
package container package container
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"mime"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -35,64 +37,84 @@ import (
const ( const (
jobLabel = "com.gitea.runner.job" jobLabel = "com.gitea.runner.job"
maxCreateBody = 8 << 20 maxCreateBody = 8 << 20
dockerProxyProbeTimeout = 5 * time.Second
) )
var ( var (
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`) createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`) rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
proxyProbe struct {
sync.Mutex
decided bool
dir string
}
) )
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files. func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
func DockerProxyDir(ctx context.Context) string { if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
proxyProbe.Lock() return nil
defer proxyProbe.Unlock()
if proxyProbe.decided {
return proxyProbe.dir
} }
dir := filepath.Join(os.TempDir(), "gitea-runner-docker") probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
ok, err := daemonSeesDir(ctx, dir) defer cancel()
cli, err := GetDockerClient(probeCtx)
if err != nil { if err != nil {
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err) return nil
return ""
}
proxyProbe.decided = true
if ok {
proxyProbe.dir = dir
} else {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
}
return proxyProbe.dir
}
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return false, err
}
probe := filepath.Join(dir, "probe")
if err := os.WriteFile(probe, nil, 0o600); err != nil {
return false, err
}
cli, err := GetDockerClient(ctx)
if err != nil {
return false, err
} }
defer cli.Close() defer cli.Close()
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
if !ok {
return nil
}
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
return nil
}
dir, err := filepath.Abs(os.TempDir())
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
seen, err := daemonSeesDir(probeCtx, cli, dir)
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
if !seen {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary filesystem, jobs get the daemon socket directly")
return nil
}
if ctx.Err() != nil {
return nil
}
proxy, err := StartDockerProxy(daemonSocket, dir, job)
if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
}
return proxy
}
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir,
// which is what a job's proxy socket mounted from there needs.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, error) {
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
if err != nil {
return false, err
}
defer func() {
if err := os.Remove(marker.Name()); err != nil {
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
}
}()
if err := marker.Close(); err != nil {
return false, err
}
images, err := cli.ImageList(ctx, client.ImageListOptions{}) images, err := cli.ImageList(ctx, client.ImageListOptions{})
if err != nil { if err != nil {
return false, err return false, err
} }
if len(images.Items) == 0 { if len(images.Items) == 0 {
return false, errors.New("no image to probe with yet") return false, errors.New("no image available for the docker proxy probe")
} }
// creating validates that a bind source exists on the daemon's side, nothing is started
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}}, Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
HostConfig: &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}}, HostConfig: &container.HostConfig{Mounts: []mount.Mount{
{Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true},
}},
}) })
if cerrdefs.IsInvalidArgument(err) { if cerrdefs.IsInvalidArgument(err) {
return false, nil return false, nil
@@ -100,24 +122,37 @@ func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true}) cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
return true, err defer cancel()
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err)
}
return true, nil
} }
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it. // StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
if err := os.MkdirAll(dir, 0o700); err != nil { info, err := os.Stat(daemonSocket)
return nil, err
}
digest := sha256.Sum256([]byte(job))
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
_ = os.Remove(socket)
listener, err := net.Listen("unix", socket)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if info, err := os.Stat(daemonSocket); err == nil { if info.Mode()&os.ModeSocket == 0 {
_ = os.Chmod(socket, info.Mode().Perm()) return nil, errors.New("docker daemon path is not a Unix socket")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
instance, err := os.MkdirTemp(dir, "p-")
if err != nil {
return nil, err
}
socket := filepath.Join(instance, "docker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
return nil, errors.Join(err, os.RemoveAll(instance))
}
if err := copyDockerSocketPermissions(socket, info); err != nil {
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
} }
dial := func(ctx context.Context, _, _ string) (net.Conn, error) { dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket) return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
@@ -130,61 +165,104 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
}, },
Transport: transport, Transport: transport,
} }
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { streams, cancelStreams := context.WithCancel(context.Background())
switch { creates, cancelCreates := context.WithCancel(context.Background())
case r.Method != http.MethodPost: var admission sync.Mutex
case createPath.MatchString(r.URL.Path): var handlers sync.WaitGroup
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admission.Lock()
if streams.Err() != nil {
admission.Unlock()
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
return
}
handlers.Add(1)
admission.Unlock()
defer handlers.Done()
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
parent, lifetime := r.Context(), streams
if creating {
parent, lifetime = context.WithoutCancel(parent), creates
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
stop := context.AfterFunc(lifetime, func() {
cancel()
if !creating {
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
_ = conn.Close()
}
}
})
defer stop()
r = r.WithContext(ctx)
if creating {
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
if err := addLabel(r, job); err != nil { if err := addLabel(r, job); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, err.Error(), status)
return return
} }
case rawStreamPath.MatchString(r.URL.Path): } else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
tunnel(w, r, dial) tunnel(w, r, dial, forward)
return return
} }
forward.ServeHTTP(w, r) forward.ServeHTTP(w, r)
})} })}
go func() { _ = server.Serve(listener) }() served := make(chan struct{})
go func() {
defer close(served)
_ = server.Serve(listener)
}()
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error { return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
err := removeJobResources(ctx, job) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
_ = server.Close() defer cancel()
admission.Lock()
listenerErr := listener.Close()
cancelStreams()
admission.Unlock()
<-served
shutdownErr := server.Shutdown(ctx)
cancelCreates()
serverErr := server.Close()
handlers.Wait()
transport.CloseIdleConnections() transport.CloseIdleConnections()
_ = os.Remove(socket) return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
return err
}}, nil }}, nil
} }
func addLabel(r *http.Request, job string) error { func addLabel(r *http.Request, job string) error {
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1)) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
return err return err
} }
if len(body) > maxCreateBody {
return errors.New("create request too large")
}
if len(bytes.TrimSpace(body)) == 0 { if len(bytes.TrimSpace(body)) == 0 {
r.Body = io.NopCloser(bytes.NewReader(body)) body = []byte("{}")
return nil
} }
var fields map[string]json.RawMessage var fields map[string]json.RawMessage
var config struct{ Labels map[string]string }
if err := json.Unmarshal(body, &fields); err != nil { if err := json.Unmarshal(body, &fields); err != nil {
return fmt.Errorf("invalid create request: %w", err) return fmt.Errorf("invalid create request: %w", err)
} }
key := "Labels" if err := json.Unmarshal(body, &config); err != nil {
for name := range fields { return fmt.Errorf("invalid create labels: %w", err)
if strings.EqualFold(name, key) {
key = name
break
}
} }
labels := map[string]string{} if fields == nil {
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" { fields = make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &labels); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
} }
labels[jobLabel] = job maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
if fields[key], err = json.Marshal(labels); err != nil { return strings.EqualFold(name, "Labels")
})
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[jobLabel] = job
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
return err return err
} }
if body, err = json.Marshal(fields); err != nil { if body, err = json.Marshal(fields); err != nil {
@@ -196,44 +274,92 @@ func addLabel(r *http.Request, job string) error {
return nil return nil
} }
type dockerProxyConnKey struct{}
type dockerProxyResponse struct {
response *http.Response
}
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
return r.response, nil
}
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade // tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) { func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
return
}
upstream, err := dial(r.Context(), "", "") upstream, err := dial(r.Context(), "", "")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
defer upstream.Close() defer upstream.Close()
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
defer stop()
if err := r.Write(upstream); err != nil { if err := r.Write(upstream); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
downstream, buffered, err := hijacker.Hijack() reader := bufio.NewReader(upstream)
var response *http.Response
for {
response, err = http.ReadResponse(reader, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
break
}
maps.Copy(w.Header(), response.Header)
w.WriteHeader(response.StatusCode)
clear(w.Header())
_ = response.Body.Close()
}
defer func() {
_ = upstream.Close()
_ = response.Body.Close()
}()
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
ordinary := *forward
ordinary.Transport = dockerProxyResponse{response: response}
ordinary.ServeHTTP(w, r)
return
}
downstream, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
return return
} }
defer downstream.Close() defer downstream.Close()
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil { if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
return return
} }
done := make(chan struct{}, 2) if err := response.Header.Write(buffered); err != nil {
return
}
if _, err := buffered.WriteString("\r\n"); err != nil {
return
}
if err := buffered.Flush(); err != nil {
return
}
done := make(chan struct{})
go func() { go func() {
_, _ = io.Copy(upstream, downstream) defer close(done)
done <- struct{}{} if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
}() _ = upstream.Close()
go func() { } else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
_, _ = io.Copy(downstream, upstream) _ = writer.CloseWrite()
done <- struct{}{} } else {
_ = upstream.Close()
}
}() }()
_, _ = io.Copy(downstream, reader)
_ = downstream.Close()
_ = upstream.Close()
<-done <-done
} }
func removeJobResources(ctx context.Context, job string) error { func RemoveDockerJobResources(ctx context.Context, job string) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
@@ -246,27 +372,20 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error
logger := common.Logger(ctx) logger := common.Logger(ctx)
filters := make(client.Filters).Add("label", jobLabel+"="+job) filters := make(client.Filters).Add("label", jobLabel+"="+job)
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters}) containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
if err != nil { errs := []error{err}
return err
}
var errs []error
for _, c := range containers.Items { for _, c := range containers.Items {
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/")) logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx)) errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
} }
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
if err != nil { errs = append(errs, err)
return errors.Join(append(errs, err)...)
}
for _, n := range networks.Items { for _, n := range networks.Items {
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err)) errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
} }
} }
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters}) volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
if err != nil { errs = append(errs, err)
return errors.Join(append(errs, err)...)
}
for _, v := range volumes.Items { for _, v := range volumes.Items {
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err)) errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
+247 -48
View File
@@ -10,18 +10,23 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"runtime"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/volume" "github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client" mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -56,7 +61,12 @@ func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
} }
func TestDockerProxy(t *testing.T) { func TestDockerProxy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket ownership is unavailable on Windows")
}
bodies := make(chan []byte, 8) 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") daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
listener, err := net.Listen("unix", daemonSocket) listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err) require.NoError(t, err)
@@ -64,16 +74,33 @@ func TestDockerProxy(t *testing.T) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/create"): case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if r.URL.RawQuery == "wait" {
close(createStarted)
<-releaseCreate
}
bodies <- body bodies <- body
w.WriteHeader(http.StatusCreated) 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() conn, buffered, err := w.(http.Hijacker).Hijack()
if err != nil { if err != nil {
return return
} }
defer conn.Close() defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n")) if r.Header.Get("Upgrade") != "" {
_, _ = io.Copy(conn, buffered) _, _ = 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: default:
w.Header().Set("Api-Version", "1.47") w.Header().Set("Api-Version", "1.47")
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path)) _, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
@@ -83,34 +110,69 @@ func TestDockerProxy(t *testing.T) {
t.Cleanup(func() { _ = daemon.Close() }) t.Cleanup(func() { _ = daemon.Close() })
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1") proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err) 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()) })
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) 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) { t.Run("labels creates", func(t *testing.T) {
for path, body := range map[string]string{ for _, testCase := range []struct{ path, body, want string }{
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`, {
"/networks/create": `{"Name":"n"}`, "/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
"/volumes/create": `{"Name":"v","labels":null}`, `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","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}`,
`{"Name":"v","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) require.NoError(t, err)
resp.Body.Close() resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode) require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
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"]))
}
} }
}) })
@@ -123,40 +185,99 @@ func TestDockerProxy(t *testing.T) {
assert.Equal(t, "OK GET /v1.47/_ping", string(body)) assert.Equal(t, "OK GET /v1.47/_ping", string(body))
}) })
t.Run("tunnels raw streams", func(t *testing.T) { t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
conn, err := net.Dial("unix", proxy.Socket) for _, stream := range []struct{ path, upgrade string }{
require.NoError(t, err) {path: "/v1.47/exec/abc/start"},
defer conn.Close() {path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
_, 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) 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) 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) require.NoError(t, err)
defer resp.Body.Close() <-createStarted
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type")) require.NoError(t, creator.CloseWrite())
echoed, err := reader.ReadString('\n') closed := make(chan error, 1)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
go func() { closed <- proxy.Close(ctx) }()
for _, reader := range []*bufio.Reader{raw, upgraded} {
_, err := reader.ReadByte()
require.ErrorIs(t, err, io.EOF)
<-streamsDone
}
select {
case err := <-closed:
t.Fatalf("Close returned before admitted create settled: %v", err)
default:
}
releaseCreate <- struct{}{}
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
require.NoError(t, err) require.NoError(t, 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) { 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() ctx := context.Background()
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1") filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
cli := &mockDockerClient{} cli := &mockDockerClient{}
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}). cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, 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) cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}). 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}). cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once()
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}). cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil) Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil) 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.ErrorContains(t, err, "failed to remove container c1")
require.ErrorContains(t, err, "failed to remove volume app_data")
cli.AssertExpectations(t) cli.AssertExpectations(t)
} }
@@ -168,13 +289,14 @@ func TestDockerProxyWithDaemon(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer direct.Close() defer direct.Close()
dir := shortTempDir(t) dir := shortTempDir(t)
seen, err := daemonSeesDir(ctx, dir) seen, err := daemonSeesDir(ctx, direct, dir)
require.NoError(t, err) require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", seen) 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) proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket)) viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(t, err) require.NoError(t, err)
defer viaProxy.Close() defer viaProxy.Close()
@@ -212,6 +334,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
attached.Close() attached.Close()
require.NoError(t, proxy.Close(ctx)) require.NoError(t, proxy.Close(ctx))
require.NoError(t, RemoveDockerJobResources(ctx, job))
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{}) _, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err)) assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{}) _, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
@@ -268,3 +391,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)
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)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
package container
import (
"errors"
"os"
"path/filepath"
"syscall"
)
func copyDockerSocketPermissions(socket string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("docker socket ownership is unavailable")
}
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err
}
return os.Chmod(socket, info.Mode().Perm())
}
+15
View File
@@ -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")
}
+17 -25
View File
@@ -488,14 +488,11 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true, RemoveVolumes: true,
Force: true, Force: true,
}) })
switch { if cerrdefs.IsConflict(err) {
case cerrdefs.IsConflict(err): err = cr.waitForRemoval(ctx, idOrName)
// the daemon's own AutoRemove teardown is running, and it releases the volume }
// references and the network endpoint only once it finishes if err != nil && !cerrdefs.IsNotFound(err) {
cr.waitForRemoval(ctx, idOrName) return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
case err != nil && !cerrdefs.IsNotFound(err):
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
return nil // keep the id, the container is still there for a later Remove()
} }
logger.Debugf("Removed container: %v", idOrName) logger.Debugf("Removed container: %v", idOrName)
@@ -504,7 +501,7 @@ func (cr *containerReference) remove() common.Executor {
} }
} }
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) { func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
// per container, against the one minute the post-job executor allows for the whole // per container, against the one minute the post-job executor allows for the whole
// cleanup, so a job with several services can spend most of that budget here // cleanup, so a job with several services can spend most of that budget here
ctx, cancel := context.WithTimeout(ctx, 15*time.Second) ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
@@ -514,18 +511,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
Condition: container.WaitConditionRemoved, Condition: container.WaitConditionRemoved,
}) })
select { select {
case <-waitResult.Result: case result := <-waitResult.Result:
case <-waitResult.Error: if result.Error != nil {
case <-ctx.Done(): return errors.New(result.Error.Message)
// the client delivers the result over an unbuffered channel, so leave a receiver }
// behind or its goroutine parks on the send for the lifetime of the process return nil
go func() { case err := <-waitResult.Error:
select { return err
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)
} }
} }
@@ -1277,15 +1269,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
type validVolumeMatcher struct { type validVolumeMatcher struct {
allowAll bool allowAll bool
named []glob.Glob named []*glob.Pattern
host []glob.Glob host []*glob.Pattern
} }
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher { func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ret := validVolumeMatcher{ ret := validVolumeMatcher{
named: make([]glob.Glob, 0, len(validVolumes)), named: make([]*glob.Pattern, 0, len(validVolumes)),
host: make([]glob.Glob, 0, len(validVolumes)), host: make([]*glob.Pattern, 0, len(validVolumes)),
} }
for _, v := range validVolumes { for _, v := range validVolumes {
+19 -12
View File
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.T) {
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not // A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one. // be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) { func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeFailure := errors.New("driver failed to remove root filesystem")
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true} removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"} killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
err error err error
wantWait bool wantWait bool
wantFailure bool waitErr error
wantErr error
}{ }{
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true}, {name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")}, {name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
{name: "removed cleanly", err: nil}, {name: "removed cleanly", err: nil},
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true}, {name: "real failure", err: removeFailure, wantErr: removeFailure},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger() logger, hook := test.NewNullLogger()
@@ -366,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err) client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
if tc.wantWait { if tc.wantWait {
removed := make(chan container.WaitResponse, 1) removed := make(chan container.WaitResponse, 1)
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}). client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
Return(mobyclient.ContainerWaitResult{Result: removed}) Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
} }
cr := &containerReference{id: "abc", cli: client} cr := &containerReference{id: "abc", cli: client}
require.NoError(t, cr.remove()(ctx)) require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
// a failure keeps the id, so a later Remove() can retry it if tc.wantErr != nil {
if tc.wantFailure {
assert.Equal(t, "abc", cr.id) assert.Equal(t, "abc", cr.id)
assert.Len(t, hook.AllEntries(), 1)
} else { } else {
assert.Empty(t, cr.id) assert.Empty(t, cr.id)
assert.Empty(t, hook.AllEntries())
} }
assert.Empty(t, hook.AllEntries())
client.AssertExpectations(t) client.AssertExpectations(t)
}) })
} }
+6 -2
View File
@@ -29,8 +29,12 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation") return false, errors.New("Unsupported Operation")
} }
func DockerProxyDir(ctx context.Context) string { func RemoveDockerJobResources(_ context.Context, _ string) error {
return "" return nil
}
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
return nil
} }
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
+1 -1
View File
@@ -147,7 +147,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
if !rc.Config.NoActionPatch { if !rc.Config.NoActionPatch {
// A concurrent job's prepare resets this directory, so patch under the copy's lock. // A concurrent job's prepare resets this directory, so patch under the copy's lock.
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel())) defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
} }
if err := removeGitIgnore(ctx, actionDir); err != nil { if err := removeGitIgnore(ctx, actionDir); err != nil {
+102
View File
@@ -0,0 +1,102 @@
// 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,
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode},
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
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")
_, 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)
}
}
+7 -6
View File
@@ -263,18 +263,19 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return err return err
}) })
stepsExecutor := newStepsExecutor(rc, preSteps, steps) return common.Executor(func(ctx context.Context) error {
if err := info.startContainer()(ctx); err != nil {
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor. return err
Finally(func(ctx context.Context) error { }
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
// Record an interrupt (backstop for interrupts that land outside the main // Record an interrupt (backstop for interrupts that land outside the main
// step loop) so the post steps observe the cancelled/failed job status. // step loop) so the post steps observe the cancelled/failed job status.
rc.markInterrupted(ctx.Err()) rc.markInterrupted(ctx.Err())
postCtx, cancel := postStepsContext(ctx) postCtx, cancel := postStepsContext(ctx)
defer cancel() defer cancel()
return postExecutor(postCtx) return postExecutor(postCtx)
}). })(ctx)
Finally(info.closeContainer())) }).Finally(info.closeContainer())
} }
// postStepsContext derives the context used to run the job's post/cleanup steps from the // postStepsContext derives the context used to run the job's post/cleanup steps from the
+38 -5
View File
@@ -300,6 +300,8 @@ func TestNewJobExecutor(t *testing.T) {
result string result string
hasError bool hasError bool
output string output string
startError error
cancelOnStart bool
}{ }{
{ {
name: "zeroSteps", name: "zeroSteps",
@@ -435,6 +437,21 @@ func TestNewJobExecutor(t *testing.T) {
result: "failure", result: "failure",
output: "${{ 'test' != test }}", output: "${{ 'test' != test }}",
}, },
{
name: "start failure",
steps: []*model.Step{{ID: "1"}},
executedSteps: []string{"startContainer", "closeContainer"},
startError: errors.New("start failed"),
},
{
name: "cancelled at startup boundary",
steps: []*model.Step{{ID: "1"}},
preSteps: []bool{false},
postSteps: []bool{true},
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "cancelled",
cancelOnStart: true,
},
} }
contains := func(needle string, haystack []string) bool { contains := func(needle string, haystack []string) bool {
@@ -445,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
ctx := common.WithJobErrorContainer(context.Background()) ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
defer cancel()
jim := &jobInfoMock{} jim := &jobInfoMock{}
sfm := &stepFactoryMock{} sfm := &stepFactoryMock{}
rc := &RunContext{ rc := &RunContext{
@@ -470,9 +488,12 @@ func TestNewJobExecutor(t *testing.T) {
jim.On("steps").Return(tt.steps) jim.On("steps").Return(tt.steps)
if len(tt.steps) > 0 { if len(tt.steps) > 0 {
jim.On("startContainer").Return(func(ctx context.Context) error { jim.On("startContainer").Return(func(_ context.Context) error {
executorOrder = append(executorOrder, "startContainer") executorOrder = append(executorOrder, "startContainer")
return nil if tt.cancelOnStart {
cancel()
}
return tt.startError
}) })
} }
@@ -506,7 +527,7 @@ func TestNewJobExecutor(t *testing.T) {
defer sm.AssertExpectations(t) defer sm.AssertExpectations(t)
} }
if len(tt.steps) > 0 { if len(tt.steps) > 0 && tt.startError == nil {
jim.On("matrix").Return(map[string]any{}) jim.On("matrix").Return(map[string]any{})
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
@@ -520,12 +541,17 @@ func TestNewJobExecutor(t *testing.T) {
if contains("stopContainer", tt.executedSteps) { if contains("stopContainer", tt.executedSteps) {
jim.On("stopContainer").Return(func(ctx context.Context) error { jim.On("stopContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "stopContainer") executorOrder = append(executorOrder, "stopContainer")
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
require.True(t, bounded)
return nil return nil
}) })
} }
jim.On("result", tt.result) jim.On("result", tt.result)
}
if len(tt.steps) > 0 {
jim.On("closeContainer").Return(func(ctx context.Context) error { jim.On("closeContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "closeContainer") executorOrder = append(executorOrder, "closeContainer")
return nil return nil
@@ -534,7 +560,14 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc) executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx) err := executor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act switch {
case tt.startError != nil:
require.ErrorIs(t, err, tt.startError)
case tt.cancelOnStart:
require.ErrorIs(t, err, context.Canceled)
default:
require.NoError(t, err)
}
assert.Empty(t, rc.Run.Job().Outputs["bad"]) assert.Empty(t, rc.Run.Job().Outputs["bad"])
assert.Equal(t, tt.executedSteps, executorOrder) assert.Equal(t, tt.executedSteps, executorOrder)
+24 -16
View File
@@ -28,8 +28,8 @@ import (
// of upload-artifact. // of upload-artifact.
// //
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1 // getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what // reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
// keeps the runner out of the artifact path: the results URL still points at Gitea. // client reaches the cache server by its own address rather than through the results origin.
// //
// Either of these landing upstream makes this file deletable: // Either of these landing upstream makes this file deletable:
// //
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
return paths return paths
} }
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's // patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
// clone lock, which is what keeps another job's checkout from resetting them before the copy. func patchActions(ctx context.Context, scripts []string) func() {
func patchActions(ctx context.Context, scripts []string) { restore := map[string][]byte{}
for _, script := range scripts { for _, script := range scripts {
switch patched, err := patchBundle(script); { original, err := patchBundle(script)
case err != nil: if original != nil {
restore[script] = original // also when the write failed part way through
}
if err != nil {
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err) common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
case patched: }
common.Logger(ctx).Debugf("actions toolkit: patched %s", script) }
return func() {
for script, original := range restore {
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
}
} }
} }
} }
func patchBundle(script string) (bool, error) { // patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
func patchBundle(script string) ([]byte, error) {
info, err := os.Stat(script) info, err := os.Stat(script)
if err != nil { if err != nil {
return false, err return nil, err
} }
if info.Size() > maxBundleSize { if info.Size() > maxBundleSize {
return false, nil return nil, nil
} }
data, err := os.ReadFile(script) data, err := os.ReadFile(script)
if err != nil { if err != nil {
return false, err return nil, err
} }
patched, ok := patchedBundle(data) patched, ok := patchedBundle(data)
if !ok { if !ok {
return false, nil return nil, nil
} }
// No atomic write needed: every prepare checks the action out and hard resets it. return data, os.WriteFile(script, patched, info.Mode().Perm())
return true, os.WriteFile(script, patched, info.Mode().Perm())
} }
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache // patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
+9 -6
View File
@@ -176,16 +176,16 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
func TestPatchBundleIsIdempotent(t *testing.T) { func TestPatchBundleIsIdempotent(t *testing.T) {
script := bundleFile(t, gateTSC) script := bundleFile(t, gateTSC)
done, err := patchBundle(script) original, err := patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
require.True(t, done) require.NotNil(t, original)
patched, err := os.ReadFile(script) patched, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
require.True(t, gateOpened(string(patched))) require.True(t, gateOpened(string(patched)))
done, err = patchBundle(script) original, err = patchBundle(script)
require.NoError(t, err) 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) again, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, string(patched), string(again)) assert.Equal(t, string(patched), string(again))
@@ -194,9 +194,9 @@ func TestPatchBundleIsIdempotent(t *testing.T) {
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) { func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
script := bundleFile(t, `console.log("checkout")`) script := bundleFile(t, `console.log("checkout")`)
done, err := patchBundle(script) original, err := patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, done) assert.Nil(t, original)
body, err := os.ReadFile(script) body, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, `console.log("checkout")`, string(body)) assert.Equal(t, `console.log("checkout")`, string(body))
@@ -251,6 +251,9 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
return nil return nil
}) })
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub")) 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 return copied
} }
+57 -65
View File
@@ -16,6 +16,7 @@ import (
"fmt" "fmt"
"io" "io"
maps0 "maps" maps0 "maps"
"net"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -270,20 +271,10 @@ func (rc *RunContext) jobDockerSocket() string {
} }
func (rc *RunContext) startDockerProxy(ctx context.Context) { func (rc *RunContext) startDockerProxy(ctx context.Context) {
daemonSocket := rc.containerDaemonSocket() if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
return return
} }
dir := container.DockerProxyDir(ctx) rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
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
} }
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
@@ -483,23 +474,17 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For gitea, to support --volumes-from <container_name_or_id> in options. // For gitea, to support --volumes-from <container_name_or_id> in options.
// We need to set the container name to the environment variable. // We need to set the container name to the environment variable.
rc.Env["JOB_CONTAINER_NAME"] = name rc.Env["JOB_CONTAINER_NAME"] = name
rc.startDockerProxy(ctx)
envList := make([]string, 0) envList := make([]string, 0)
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{}
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>) // 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. // if using service containers, will create a new network for the containers.
// and it will be removed after at last. // and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// add service containers // add service containers
for serviceID, spec := range rc.Run.Job().Services { for serviceID, spec := range rc.Run.Job().Services {
@@ -590,8 +575,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c}) 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` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
@@ -599,7 +582,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
if err != nil { if err != nil {
return err return err
} }
rc.JobContainer = newContainer(&container.NewContainerInput{ ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -608,10 +592,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
Password: password, Password: password,
Name: name, Name: name,
Env: envList, Env: envList,
Mounts: mounts,
NetworkMode: jobContainerNetwork, NetworkMode: jobContainerNetwork,
NetworkAliases: []string{rc.Name}, NetworkAliases: []string{rc.Name},
Binds: binds,
Stdout: logWriter, Stdout: logWriter,
Stderr: logWriter, Stderr: logWriter,
Privileged: rc.Config.Privileged, Privileged: rc.Config.Privileged,
@@ -620,20 +602,29 @@ func (rc *RunContext) startJobContainer() common.Executor {
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: workflowOptions, WorkflowOptions: workflowOptions,
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }
rc.JobContainer = newContainer(containerInput)
if rc.JobContainer == nil { if rc.JobContainer == nil {
return errors.New("failed to create job container") return errors.New("failed to create job container")
} }
defer printStartJobContainerGroup(ctx, image, name, networkName)()
if err := common.NewPipelineExecutor(
rc.stopJobContainer(),
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
).Finally(rc.closeContainer())(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 rc.jobNetworkName = networkName
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.stopJobContainer(),
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions). container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork), IfBool(createAndDeleteNetwork),
rc.startServiceContainers(), rc.startServiceContainers(),
@@ -663,47 +654,46 @@ 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 bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
removeJobContainer := rc.JobContainer != nil errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil {
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx)) errs = append(errs, rc.JobContainer.Remove()(ctx))
} }
if len(rc.serviceContainers) > 0 { if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName) logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil { errs = append(errs, rc.stopServiceContainers()(ctx))
logger.Errorf("Error while cleaning services: %v", err)
}
} }
if rc.dockerProxy != nil { if !common.Dryrun(ctx) {
if err := rc.dockerProxy.Close(ctx); err != nil { errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
logger.Errorf("Error while removing what the job created: %v", err)
}
rc.dockerProxy = nil
} }
if removeJobContainer { if rc.JobContainer != nil {
// after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName() name := rc.jobContainerName()
errs = append(errs, errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx), container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx)) container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
} }
if createAndDeleteNetwork { if createAndDeleteNetwork {
// last, once every container has detached
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName) logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil { errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
logger.Errorf("Error while cleaning network: %v", err)
}
} }
return errors.Join(errs...) return errors.Join(errs...)
} }
} }
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
if rc.dockerProxy == nil {
return nil
}
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) { func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
if len(rc.ExtraPath) > 0 { if len(rc.ExtraPath) > 0 {
path := rc.JobContainer.GetPathVariableName() path := rc.JobContainer.GetPathVariableName()
@@ -763,7 +753,6 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
return nil return nil
} }
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
func (rc *RunContext) stopJobContainer() common.Executor { func (rc *RunContext) stopJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil { if rc.cleanUpJobContainer != nil {
@@ -800,10 +789,17 @@ func (rc *RunContext) startServiceContainers() common.Executor {
func (rc *RunContext) stopServiceContainers() common.Executor { func (rc *RunContext) stopServiceContainers() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
execs := []common.Executor{} execs := []common.Executor{}
for _, svc := range rc.serviceContainers { errs := make([]error, len(rc.serviceContainers))
execs = append(execs, svc.container.Remove().Finally(svc.container.Close())) 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...)
} }
} }
@@ -1033,17 +1029,13 @@ func (rc *RunContext) startContainer() common.Executor {
} }
func (rc *RunContext) cleanupFailedStart(ctx context.Context) { func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
if rc.cleanUpJobContainer == nil { cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
return defer cancel()
cleanup := rc.cleanUpJobContainer
if cleanup == nil {
cleanup = rc.closeDockerProxy
} }
cleanCtx := ctx if err := cleanup(cleanCtx); err != nil {
if ctx.Err() != nil {
// the start likely failed because ctx was cancelled, detach so teardown still runs
var cancel context.CancelFunc
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
}
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err) common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
} }
} }
@@ -1655,7 +1647,7 @@ func imageOSFromImage(image string) string {
func setActionRuntimeVars(rc *RunContext, env map[string]string) { func setActionRuntimeVars(rc *RunContext, env map[string]string) {
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL") actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
if actionsRuntimeURL == "" { 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 env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
+51 -13
View File
@@ -10,6 +10,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"runtime" "runtime"
"strings" "strings"
@@ -221,8 +223,9 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) GetActPath() string { return "/var/run/act" } func (fakeContainer) GetActPath() string { return "/var/run/act" }
func (fakeContainer) ToContainerPath(path string) string { return path }
func (fakeContainer) Create([]string, []string) common.Executor { func (fakeContainer) Create([]string, []string) common.Executor {
return func(context.Context) error { return nil } return func(context.Context) error { return nil }
} }
@@ -269,9 +272,26 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context())) require.NoError(t, rc.resolvePlatformImage(t.Context()))
// the inputs are built before the missing daemon fails the first call daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") w.Header().Set("Content-Type", "application/json")
require.Error(t, rc.startJobContainer()(t.Context())) switch {
case strings.HasSuffix(r.URL.Path, "/_ping"):
w.Header().Set("API-Version", "1.47")
_, _ = io.WriteString(w, "OK")
case strings.HasSuffix(r.URL.Path, "/containers/json"), strings.HasSuffix(r.URL.Path, "/networks"):
_, _ = io.WriteString(w, "[]")
case strings.HasSuffix(r.URL.Path, "/volumes"):
_, _ = io.WriteString(w, `{"Volumes":[]}`)
case strings.HasSuffix(r.URL.Path, "/info"):
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
default:
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
http.NotFound(w, r)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
require.NoError(t, rc.startJobContainer()(t.Context()))
return inputs return inputs
} }
@@ -627,7 +647,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
} }
err := rc.cleanupJobResources("external-network", false)(context.Background()) err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true))
require.NoError(t, err) require.NoError(t, err)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -636,11 +656,12 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
removeError, closeError := errors.New("remove service"), errors.New("close service")
jobContainer := &containerMock{} jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once() jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
service := &containerMock{} service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return nil }).Once() service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once() service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Name: "job", Name: "job",
@@ -652,7 +673,11 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx)) err := rc.cleanupJobResources("job-network", true)(ctx)
require.ErrorContains(t, err, "removal failed")
require.ErrorIs(t, err, removeError)
require.ErrorIs(t, err, closeError)
require.ErrorIs(t, err, context.Canceled)
jobContainer.AssertExpectations(t) jobContainer.AssertExpectations(t)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -1069,12 +1094,19 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
calls int calls int
err error err error
sentinel any sentinel any
cancel context.CancelFunc
} }
newRC := func(c *capture) *RunContext { newRC := func(c *capture) *RunContext {
return &RunContext{ return &RunContext{
JobName: "job", JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error { cleanUpJobContainer: func(ctx context.Context) error {
c.calls++ c.calls++
if c.cancel != nil {
c.cancel()
}
deadline, ok := ctx.Deadline()
require.True(t, ok)
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
c.err = ctx.Err() c.err = ctx.Err()
c.sentinel = ctx.Value(sentinel) c.sentinel = ctx.Value(sentinel)
return nil return nil
@@ -1082,9 +1114,10 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
} }
} }
t.Run("runs teardown on the live context", func(t *testing.T) { t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) {
var c capture ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
ctx := context.WithValue(context.Background(), sentinel, "v") defer cancel()
c := capture{cancel: cancel}
newRC(&c).cleanupFailedStart(ctx) newRC(&c).cleanupFailedStart(ctx)
@@ -1102,7 +1135,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.Equal(t, 1, c.calls) assert.Equal(t, 1, c.calls)
require.NoError(t, c.err) require.NoError(t, c.err)
assert.Nil(t, c.sentinel) assert.Equal(t, "v", c.sentinel)
}) })
t.Run("no-op when there is nothing to clean up", func(t *testing.T) { t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
@@ -1472,6 +1505,10 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1" rc.Config.RunnerName = "runner-1"
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"} rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
t.Setenv("ACTIONS_RUNTIME_URL", "")
rc.Config.ArtifactServerPath = "artifacts"
rc.Config.ArtifactServerAddr = "2001:db8::1"
rc.Config.ArtifactServerPort = "8080"
env := map[string]string{} env := map[string]string{}
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env) rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
@@ -1480,4 +1517,5 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"]) assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"]) assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
assert.Equal(t, "1", env["RUNNER_DEBUG"]) assert.Equal(t, "1", env["RUNNER_DEBUG"])
assert.Equal(t, "http://[2001:db8::1]:8080/", env["ACTIONS_RUNTIME_URL"])
} }
+6
View File
@@ -0,0 +1,6 @@
name: docker-proxy
description: Verify mounted Docker access through post steps
runs:
using: node24
main: index.js
post: index.js
+53
View File
@@ -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;
});
+8
View File
@@ -0,0 +1,8 @@
name: docker-proxy
on: push
jobs:
proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./action
+21
View File
@@ -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)
})
}
}
+1
View File
@@ -27,6 +27,7 @@ func TestCompatibility(t *testing.T) {
t.Run("payloads", testPayloads) t.Run("payloads", testPayloads)
t.Run("cache", testActionsCacheRoundTrip) t.Run("cache", testActionsCacheRoundTrip)
t.Run("artifact", testArtifactRoundTrip)
t.Run("cancellation_and_log_streaming", testRunCancellation) t.Run("cancellation_and_log_streaming", testRunCancellation)
t.Run("dispatch", testWorkflowDispatch) t.Run("dispatch", testWorkflowDispatch)
t.Run("ephemeral", testEphemeralRunner) t.Run("ephemeral", testEphemeralRunner)
+2 -2
View File
@@ -160,7 +160,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
sharedNet, _ = selfNetwork(ctx, cli) sharedNet, _ = selfNetwork(ctx, cli)
) )
if sharedNet != "" { if sharedNet != "" {
baseURL = fmt.Sprintf("http://%s:3000", name) baseURL = "http://" + net.JoinHostPort(name, "3000")
netConfig = &network.NetworkingConfig{ netConfig = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}, EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
} }
@@ -170,7 +170,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
if err != nil { if err != nil {
return nil, fmt.Errorf("find a free host port: %w", err) 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{ hostConfig.PortBindings = network.PortMap{
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}}, containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
} }
+2
View File
@@ -27,6 +27,7 @@ type runnerOptions struct {
capacity int capacity int
ephemeral bool ephemeral bool
cacheV2 *bool cacheV2 *bool
cacheHost string
} }
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller { 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.Container.DockerHost = "unix:///var/run/docker.sock"
} }
cfg.Cache.Dir = t.TempDir() + "/cache" cfg.Cache.Dir = t.TempDir() + "/cache"
cfg.Cache.Host = options.cacheHost
cfg.Runner.Insecure = true cfg.Runner.Insecure = true
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
+28
View File
@@ -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
+4 -4
View File
@@ -2,7 +2,7 @@ module gitea.com/gitea/runner
go 1.27 go 1.27
toolchain go1.27.0 toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
@@ -17,7 +17,7 @@ require (
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.5.0
github.com/go-git/go-billy/v5 v5.9.1 github.com/go-git/go-billy/v5 v5.9.1
github.com/go-git/go-git/v5 v5.19.2 github.com/go-git/go-git/v5 v5.19.2
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v1.0.0
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
@@ -30,8 +30,8 @@ require (
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.15.1 github.com/opencontainers/selinux v1.15.1
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2 github.com/prometheus/client_model v0.6.3
github.com/sirupsen/logrus v1.10.1 github.com/sirupsen/logrus v1.10.2
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
+6 -6
View File
@@ -77,8 +77,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -157,8 +157,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
@@ -173,8 +173,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+2 -6
View File
@@ -6,8 +6,6 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"os/signal"
"gitea.com/gitea/runner/act/artifactcache" "gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/internal/app/run" "gitea.com/gitea/runner/internal/app/run"
@@ -67,10 +65,8 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL()) log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
c := make(chan os.Signal, 1) <-cmd.Context().Done()
signal.Notify(c, os.Interrupt)
<-c
return nil return cacheHandler.Close()
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ func Execute(ctx context.Context) {
// hide completion command // hide completion command
rootCmd.CompletionOptions.HiddenDefaultCmd = true rootCmd.CompletionOptions.HiddenDefaultCmd = true
if err := rootCmd.Execute(); err != nil { if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1) os.Exit(1)
} }
} }
+6 -1
View File
@@ -5,6 +5,7 @@
package cmd package cmd
import ( import (
"cmp"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
@@ -187,7 +188,10 @@ func (i *executeArgs) LoadEnvs() map[string]string {
envs := parseKVAndFile(i.envs, i.Envfile()) envs := parseKVAndFile(i.envs, i.Envfile())
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
// The same server answers the cache service v2 API, so let the actions reach it. // The same server answers cache v2, which docker buildx reads from the results origin alone.
if envs["ACTIONS_RESULTS_URL"] == "" {
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
}
envs[runner.CacheServiceV2Env] = "true" envs[runner.CacheServiceV2Env] = "true"
return envs return envs
@@ -540,6 +544,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
} }
config.Env["ACT_EXEC"] = "true" config.Env["ACT_EXEC"] = "true"
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
if t := config.Secrets["GITEA_TOKEN"]; t != "" { if t := config.Secrets["GITEA_TOKEN"]; t != "" {
config.Token = t config.Token = t
+19
View File
@@ -12,9 +12,12 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/act/runner"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -307,3 +310,19 @@ func captureStdout(t *testing.T, fn func()) string {
require.NoError(t, r.Close()) require.NoError(t, r.Close())
return buf.String() return buf.String()
} }
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
args := &executeArgs{cacheHandler: handler}
envs := args.LoadEnvs()
assert.Equal(t, handler.ExternalURL(), envs["ACTIONS_RESULTS_URL"],
"the v2 flag is advertised, so something has to serve that origin")
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
args.envs = []string{"ACTIONS_RESULTS_URL=https://gitea.example"}
assert.Equal(t, "https://gitea.example", args.LoadEnvs()["ACTIONS_RESULTS_URL"], "a supplied origin wins")
}
+48 -13
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -89,7 +90,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs := make(map[string]string, len(cfg.Runner.Envs)) envs := make(map[string]string, len(cfg.Runner.Envs))
maps.Copy(envs, cfg.Runner.Envs) maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler var cacheHandler *artifactcache.Handler
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled { if cacheEnabled(cfg) {
if cfg.Cache.ExternalServer != "" { if cfg.Cache.ExternalServer != "" {
warnIgnoredCachePolicy(cfg) warnIgnoredCachePolicy(cfg)
// The v1 client appends its path to this without a separator, so the slash is required. // The v1 client appends its path to this without a separator, so the slash is required.
@@ -109,6 +110,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
} else { } else {
cacheHandler = handler cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
warnIfCacheUnreachable(cfg, handler.ExternalURL())
} }
} }
} }
@@ -279,10 +281,9 @@ func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.Decl
} }
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error { func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
if _, ok := r.runningTasks.Load(task.Id); ok { if _, ok := r.runningTasks.LoadOrStore(task.Id, struct{}{}); ok {
return fmt.Errorf("task %d is already running", task.Id) return fmt.Errorf("task %d is already running", task.Id)
} }
r.runningTasks.Store(task.Id, struct{}{})
defer r.runningTasks.Delete(task.Id) defer r.runningTasks.Delete(task.Id)
r.runningCount.Add(1) r.runningCount.Add(1)
@@ -388,7 +389,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// Added per task because this job's service containers must be reached directly, and // Added per task because this job's service containers must be reached directly, and
// act reaches them by their workflow key. // act reaches them by their workflow key.
proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services))) proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services)))
maps.Copy(envs, proxyEnv) maps.Copy(envs, proxyEnv)
if r.capabilities != "" { if r.capabilities != "" {
@@ -454,14 +455,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// is that server's responsibility to authenticate requests. // is that server's responsibility to authenticate requests.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter) revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
defer revokeCache() defer revokeCache()
// A cache server that agreed to forward the artifact half is the whole results service, so the r.setResultsService(envs, resultsURL)
// job is pointed at it.
if resultsURL != "" {
envs["ACTIONS_RESULTS_URL"] = resultsURL
if r.cacheServiceV2() {
envs[runner.CacheServiceV2Env] = "true"
}
}
eventJSON, err := json.Marshal(preset.Event) eventJSON, err := json.Marshal(preset.Event)
if err != nil { if err != nil {
@@ -584,12 +578,43 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr return execErr
} }
func (r *Runner) builtInCacheURL() string {
if r.cacheHandler == nil {
return ""
}
return r.envs["ACTIONS_CACHE_URL"]
}
func cacheEnabled(cfg *config.Config) bool {
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
}
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2 // cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
// turns off: the bundle edit that reaches it is what the artifact actions need too. // turns off: the bundle edit that reaches it is what the artifact actions need too.
func (r *Runner) cacheServiceV2() bool { func (r *Runner) cacheServiceV2() bool {
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2 return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
} }
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
v2 := resultsURL != "" && r.cacheServiceV2()
if v2 {
envs[runner.CacheServiceV2Env] = "true"
} else {
delete(envs, runner.CacheServiceV2Env)
}
// Cache v2 clients read no other variable for it, and some cannot use the instance address.
if v2 || (resultsURL != "" && r.instanceOutOfReach(envs["ACTIONS_RESULTS_URL"])) {
envs["ACTIONS_RESULTS_URL"] = resultsURL
}
}
// These clients keep only the origin they are handed, and reach it on the job's own trust.
func (r *Runner) instanceOutOfReach(instance string) bool {
parsed, err := url.Parse(instance)
return err != nil || strings.Trim(parsed.Path, "/") != "" ||
(r.cfg.Runner.Insecure && parsed.Scheme == "https")
}
// registerCacheForTask tells the cache server to accept requests authenticated // registerCacheForTask tells the cache server to accept requests authenticated
// with the given runtime token for the duration of this task. Returns a // with the given runtime token for the duration of this task. Returns a
// function the caller must invoke (typically via defer) to revoke the // function the caller must invoke (typically via defer) to revoke the
@@ -616,7 +641,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
if r.cacheHandler != nil { if r.cacheHandler != nil {
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred) return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
} }
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, cred, reporter) return r.registerExternalCacheJob(token, cred, reporter)
} }
// No cache server to register against: caching is disabled, or the built-in server failed to start. // No cache server to register against: caching is disabled, or the built-in server failed to start.
@@ -799,3 +824,13 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
} }
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key) log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
} }
func warnIfCacheUnreachable(cfg *config.Config, cacheURL string) {
if cfg.Cache.Host != "" || cfg.Container.Network != "" {
return
}
if _, err := os.Stat("/.dockerenv"); err != nil {
return
}
log.Warnf("jobs are given %s for the cache server; if they cannot reach it, set container.network to a network this runner is on, or cache.host", cacheURL)
}
+7
View File
@@ -194,6 +194,13 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, probe(), require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration") "token must be unknown to the remote server before registration")
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
}}}
disabled.registerCacheForTask(token, repo, nil)
require.Equal(t, http.StatusUnauthorized, probe(),
"a disabled cache registers nothing, whatever external server is left configured")
unregister, resultsURL := r.registerCacheForTask(token, repo, nil) unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(), require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask") "token must be accepted after registerCacheForTask")
+31 -3
View File
@@ -143,6 +143,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
require.NotContains(t, r.envs, "http_proxy") require.NotContains(t, r.envs, "http_proxy")
require.NotContains(t, r.envs, "no_proxy") require.NotContains(t, r.envs, "no_proxy")
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours")
} }
func taskWithDefaultActionsURL(url string) *runnerv1.Task { func taskWithDefaultActionsURL(url string) *runnerv1.Task {
@@ -189,11 +190,38 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service") assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
envs := r.cloneEnvs()
r.setResultsService(envs, resultsURL)
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"])
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
// Turning v2 off withdraws the advertisement and nothing else.
assert.True(t, r.cacheServiceV2())
cfg.Cache.V2 = new(bool) cfg.Cache.V2 = new(bool)
assert.False(t, r.cacheServiceV2()) envs = r.cloneEnvs()
envs[runner.CacheServiceV2Env] = "true"
r.setResultsService(envs, resultsURL)
assert.Equal(t, "https://gitea.example", envs["ACTIONS_RESULTS_URL"])
assert.Empty(t, envs[runner.CacheServiceV2Env], "a runner.envs entry would promise v2 at an origin not serving it")
envs = r.cloneEnvs()
envs[runner.CacheServiceV2Env] = "true"
envs["ACTIONS_RESULTS_URL"] = "https://gitea.example/sub"
r.setResultsService(envs, "")
assert.Equal(t, "https://gitea.example/sub", envs["ACTIONS_RESULTS_URL"], "with no cache server there is nothing to front with")
assert.Empty(t, envs[runner.CacheServiceV2Env])
for instance, insecure := range map[string]bool{
"https://gitea.example/sub": false,
"https://self-signed.example": true,
} {
cfg.Runner.Insecure = insecure
envs = r.cloneEnvs()
envs["ACTIONS_RESULTS_URL"] = instance
r.setResultsService(envs, resultsURL)
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
assert.Empty(t, envs[runner.CacheServiceV2Env])
}
} }
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured // The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
+5 -4
View File
@@ -139,7 +139,8 @@ runner:
# job_completed: '' # job_completed: ''
cache: cache:
# Enable the built-in cache server (used by actions/cache and similar actions). # Enable caching (used by actions/cache and similar actions). Off means no built-in server is
# started and no job is registered with an external_server either.
#enabled: true #enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache # Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set. # Ignored when external_server is set.
@@ -176,9 +177,9 @@ cache:
# until its cache entry expires or is manually removed. # until its cache entry expires or is manually removed.
#offline_mode: false #offline_mode: false
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not # Serve the actions cache service v2 API. The actions that use it refuse any host they do not
# take for GitHub, so reaching it means editing that check out of their own bundle, undone # take for GitHub, so reaching it means editing that check out of their own bundle, put back
# whenever it is downloaded again. That edit is made either way, this only governs the API # after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. # advertised. A bundle that does not match is left alone. With v2, uploads need a reachable cache.
#v2: true #v2: true
# How the cache server discards entries, ignored when external_server is set since that # How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three # server applies its own. Leave a setting out for its default; 0s or 0 turns the three
+56 -9
View File
@@ -9,10 +9,8 @@
# #
# Usage: scripts/test-dind.sh [target] [-- go-test-args...] # Usage: scripts/test-dind.sh [target] [-- go-test-args...]
# target: dind (default) or dind-rootless # target: dind (default) or dind-rootless
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests # go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't # symlink copying and a mounted Docker job using cached images.
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
# /var/run symlink copy regression (which reuses a preloaded alpine).
# #
# Env: # Env:
# DIND_TEST_PORT host port for the daemon (default 32375) # DIND_TEST_PORT host port for the daemon (default 32375)
@@ -25,15 +23,25 @@ case "${1:-}" in
dind|dind-rootless) target="$1"; shift ;; dind|dind-rootless) target="$1"; shift ;;
esac esac
[ "${1:-}" = "--" ] && shift [ "${1:-}" = "--" ] && shift
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/ default_tests=false
if [ $# -eq 0 ]; then
default_tests=true
set -- -count=1 -race -run '^TestDocker$' ./act/container/
fi
port="${DIND_TEST_PORT:-32375}" port="${DIND_TEST_PORT:-32375}"
name="gitea-runner-dind-test-$$" name="gitea-runner-dind-test-$$"
image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}" image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}"
# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon. # The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon.
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}" host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
test_dir=""
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; } cleanup() {
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
if [ -n "$test_dir" ]; then
rm -rf "$test_dir"
fi
}
trap cleanup EXIT trap cleanup EXIT
if [ -z "${DIND_TEST_IMAGE:-}" ]; then if [ -z "${DIND_TEST_IMAGE:-}" ]; then
@@ -71,7 +79,7 @@ else
fi fi
# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting # Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting
# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind. # DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind.
docker run -d --privileged --name "$name" "${run_args[@]}" \ docker -H "$host_docker" run -d --privileged --name "$name" "${run_args[@]}" \
-e DOCKER_TLS_CERTDIR= \ -e DOCKER_TLS_CERTDIR= \
--entrypoint dockerd-entrypoint.sh \ --entrypoint dockerd-entrypoint.sh \
"$image" --host=tcp://0.0.0.0:2375 >/dev/null "$image" --host=tcp://0.0.0.0:2375 >/dev/null
@@ -82,11 +90,20 @@ for _ in $(seq 1 60); do
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
sleep 1 sleep 1
done done
if ! docker version --format 'server docker {{.Server.Version}}'; then
docker -H "$host_docker" logs "$name" >&2
exit 1
fi
# Seed the fresh daemon with images the host already has (the CI job pulls them in the # Seed the fresh daemon with images the host already has (the CI job pulls them in the
# preceding `make test`), so the daemon-facing tests run without registry access. # preceding `make test`), so the daemon-facing tests run without registry access.
echo "==> Seeding daemon with cached host images" echo "==> Seeding daemon with cached host images"
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do preload="${DIND_TEST_PRELOAD:-alpine:latest}"
job_image="${ACT_TEST_IMAGE:-node:24-bookworm-slim}"
if [ "$default_tests" = true ]; then
preload="$preload $job_image"
fi
for img in $preload; do
if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then
docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true
fi fi
@@ -94,3 +111,33 @@ done
echo "==> Running tests against dind daemon" echo "==> Running tests against dind daemon"
go test "$@" go test "$@"
if [ "$default_tests" = true ]; then
if ! docker image inspect "$job_image" >/dev/null 2>&1; then
echo "mounted Docker test requires ${job_image}, pull it on the host before running this harness" >&2
exit 1
fi
test_dir="$(mktemp -d)"
echo "==> Building mounted Docker test for the dind container"
CGO_ENABLED=0 GOOS=linux GOARCH="$(docker -H "$host_docker" image inspect "$image" --format '{{.Architecture}}')" \
go test -c -o "$test_dir/runner.test" ./act/runner/
docker -H "$host_docker" exec "$name" mkdir -p /tmp/gitea-runner-proxy-test/testdata
tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \
docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test
socket="unix:///var/run/docker.sock"
users=(0)
if [ "$target" = dind-rootless ]; then
socket="unix:///run/user/1000/docker.sock"
users=(1000 0)
fi
for user in "${users[@]}"; do
proxy_mode="proxy"
if [ "$user" != 0 ]; then
proxy_mode="direct"
fi
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access"
docker -H "$host_docker" exec --user "$user" -w /tmp/gitea-runner-proxy-test \
-e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \
"$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
done
fi