diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 3bad0aaf..b381f769 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -11,7 +11,6 @@ on: env: DOCKER_ORG: gitea - DOCKER_LATEST: nightly jobs: goreleaser: diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 5a3029fd..b2c983b8 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -69,7 +69,6 @@ jobs: image: catthehacker/ubuntu:act-latest env: DOCKER_ORG: gitea - DOCKER_LATEST: latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5fcc4780..bd0d651d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -28,38 +28,18 @@ builds: ignore: - goos: darwin goarch: arm - - goos: darwin - goarch: ppc64le - goos: darwin goarch: s390x - - goos: windows - goarch: ppc64le - goos: windows goarch: s390x - goos: windows goarch: arm - goarm: "5" - - goos: windows - goarch: arm - goarm: "6" - - goos: windows - goarch: arm - goarm: "7" - goos: windows goarch: arm64 - - goos: freebsd - goarch: ppc64le - goos: freebsd goarch: s390x - goos: freebsd goarch: arm - goarm: "5" - - goos: freebsd - goarch: arm - goarm: "6" - - goos: freebsd - goarch: arm - goarm: "7" - goos: freebsd goarch: arm64 flags: diff --git a/Makefile b/Makefile index 90b74cec..d6da9e62 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,6 @@ GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "genera DOCKER_IMAGE ?= gitea/runner DOCKER_TAG ?= nightly DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) -DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go @@ -34,15 +33,12 @@ endif ifeq ($(OS), Windows_NT) GOFLAGS := -v -buildmode=exe - EXECUTABLE ?= $(EXECUTABLE).exe GO_ENV_WINDOWS := set GOOS=windows&& else ifeq ($(OS), Windows) GOFLAGS := -v -buildmode=exe - EXECUTABLE ?= $(EXECUTABLE).exe GO_ENV_WINDOWS := set GOOS=windows&& else GOFLAGS := -v - EXECUTABLE ?= $(EXECUTABLE) GO_ENV_WINDOWS := GOOS=windows endif diff --git a/act/artifactcache/handler.go b/act/artifactcache/handler.go index 193da429..04345b33 100644 --- a/act/artifactcache/handler.go +++ b/act/artifactcache/handler.go @@ -81,7 +81,6 @@ type Handler struct { dir string storage cacheStorage s3 *s3Storage - router *http.ServeMux listener net.Listener port int server *http.Server @@ -200,11 +199,9 @@ func StartHandler(opts Options) (*Handler, error) { h.storage = h.s3 } - secret, err := loadOrCreateSecret(dir) - if err != nil { + if h.secret, err = loadOrCreateSecret(dir); err != nil { return nil, err } - h.secret = secret router := http.NewServeMux() router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find)) @@ -223,8 +220,6 @@ func StartHandler(opts Options) (*Handler, error) { h.registerV2Routes(router) router.HandleFunc(artifactServicePath, h.forwardOrNotFound) - h.router = router - h.gcCache() if err := h.serve(opts.Port, router); err != nil { @@ -333,16 +328,7 @@ func (h *Handler) RegisterJob(token string, cred JobCredential) func() { } } h.credMu.Unlock() - return func() { - h.credMu.Lock() - if entry, ok := h.creds[token]; ok { - entry.refs-- - if entry.refs <= 0 { - delete(h.creds, token) - } - } - h.credMu.Unlock() - } + return func() { h.RevokeJob(token) } } // RevokeJob explicitly revokes one registration of token, mirroring one call @@ -405,10 +391,7 @@ func (h *Handler) Close() error { } var retErr error if h.server != nil { - err := h.server.Close() - if err != nil { - retErr = err - } + retErr = h.server.Close() h.server = nil } if h.listener != nil { @@ -774,7 +757,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) { return } db.Close() - start, _, err := parseContentRange(r.Header.Get("Content-Range")) + start, err := parseContentRange(r.Header.Get("Content-Range")) if err != nil { h.responseJSON(w, r, 400, err) return @@ -1041,10 +1024,8 @@ func bearerToken(r *http.Request) string { } func credFromContext(ctx context.Context) JobCredential { - if cred, ok := ctx.Value(credKey{}).(JobCredential); ok { - return cred - } - return JobCredential{} + cred, _ := ctx.Value(credKey{}).(JobCredential) + return cred } // computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the @@ -1209,9 +1190,6 @@ func (p Policy) withDefaults() Policy { } func (h *Handler) gcCache() { - if h.gcing.Load() { - return - } if !h.gcing.CompareAndSwap(false, true) { return } @@ -1458,18 +1436,17 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, _, _ = w.Write(data) } -func parseContentRange(s string) (int64, int64, error) { +func parseContentRange(s string) (int64, error) { // support the format like "bytes 11-22/*" only s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/") s1, s2, _ := strings.Cut(s, "-") start, err := strconv.ParseInt(s1, 10, 64) if err != nil { - return 0, 0, fmt.Errorf("parse %q: %w", s, err) + return 0, fmt.Errorf("parse %q: %w", s, err) } - stop, err := strconv.ParseInt(s2, 10, 64) - if err != nil { - return 0, 0, fmt.Errorf("parse %q: %w", s, err) + if _, err := strconv.ParseInt(s2, 10, 64); err != nil { + return 0, fmt.Errorf("parse %q: %w", s, err) } - return start, stop, nil + return start, nil } diff --git a/act/artifactcache/handler_test.go b/act/artifactcache/handler_test.go index 0833356b..6f7fdd46 100644 --- a/act/artifactcache/handler_test.go +++ b/act/artifactcache/handler_test.go @@ -26,10 +26,6 @@ import ( berrors "go.etcd.io/bbolt/errors" ) -// testToken is registered with the cache server in every test that needs to -// make authenticated requests; testClient then attaches it as the -// Authorization: Bearer header. testRepo is the repository scope used when -// registering it; cross-repo isolation is exercised in its own test. const ( testToken = "test-runtime-token" testRepo = "owner/repo" @@ -44,12 +40,8 @@ func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { var testClient = &http.Client{Transport: &bearerTransport{token: testToken}} -// testRetention mirrors config.DefaultCacheRetention; Policy has no defaults of its own. const testRetention = 7 * 24 * time.Hour -// signArtifactURL builds a signed download URL the same way the server does; -// tests use it to reach the get handler directly without going through a -// find/cache-hit round trip. func signArtifactURL(h *Handler, id int64) string { return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL)) } @@ -108,6 +100,7 @@ func TestHandler_Upstream(t *testing.T) { } func TestHandler(t *testing.T) { + const version = "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) require.NoError(t, err) @@ -161,7 +154,6 @@ func TestHandler(t *testing.T) { t.Run("get not exist", func(t *testing.T) { key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) require.NoError(t, err) defer resp.Body.Close() @@ -170,7 +162,6 @@ func TestHandler(t *testing.T) { t.Run("reserve and upload", func(t *testing.T) { key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" content := make([]byte, 100) _, err := rand.Read(content) require.NoError(t, err) @@ -185,164 +176,73 @@ func TestHandler(t *testing.T) { }) t.Run("reserve with bad request", func(t *testing.T) { - body := []byte(`invalid json`) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) + resp, err := testClient.Post(base+"/caches", "application/json", strings.NewReader(`invalid json`)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, 400, resp.StatusCode) }) t.Run("duplicate reserve", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var first, second struct { - CacheID uint64 `json:"cacheId"` + var reserved [2]uint64 + for i := range reserved { + reserved[i] = reserveCache(t, testClient, base, strings.ToLower(t.Name()), version, 100) } - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - require.NoError(t, json.UnmarshalRead(resp.Body, &first)) - assert.NotZero(t, first.CacheID) - } - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - require.NoError(t, json.UnmarshalRead(resp.Body, &second)) - assert.NotZero(t, second.CacheID) - } - - assert.NotEqual(t, first.CacheID, second.CacheID) + assert.NotEqual(t, reserved[0], reserved[1]) }) - t.Run("upload with bad id", func(t *testing.T) { - req, err := http.NewRequest(http.MethodPatch, - base+"/caches/invalid_id", bytes.NewReader(nil)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes 0-99/*") - resp, err := testClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 400, resp.StatusCode) - }) - - t.Run("upload without reserve", func(t *testing.T) { - req, err := http.NewRequest(http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, 1000), bytes.NewReader(nil)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes 0-99/*") - resp, err := testClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 400, resp.StatusCode) - }) - - t.Run("upload with complete", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var id uint64 - content := make([]byte, 100) - _, err := rand.Read(content) - require.NoError(t, err) - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) + for _, tc := range []struct { + name string + method string + id string + }{ + {"upload with bad id", http.MethodPatch, "invalid_id"}, + {"upload without reserve", http.MethodPatch, "1000"}, + {"commit with bad id", http.MethodPost, "invalid_id"}, + {"commit with not exist id", http.MethodPost, "100"}, + } { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(tc.method, base+"/caches/"+tc.id, nil) require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID - } - { - req, err := http.NewRequest(http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes 0-99/*") - resp, err := testClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - } - { - resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - } - { - req, err := http.NewRequest(http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes 0-99/*") + if tc.method == http.MethodPatch { + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Range", "bytes 0-99/*") + } resp, err := testClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, 400, resp.StatusCode) - } - }) + }) + } + + for _, tc := range []struct { + name string + method string + }{ + {"upload with complete", http.MethodPatch}, + {"duplicate commit", http.MethodPost}, + } { + t.Run(tc.name, func(t *testing.T) { + content := []byte("cache content") + id := uploadCacheNormally(t, base, strings.ToLower(t.Name()), version, content) + req, err := http.NewRequest(tc.method, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Range", "bytes 0-12/*") + resp, err := testClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, 400, resp.StatusCode) + }) + } t.Run("upload with invalid range", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var id uint64 - content := make([]byte, 100) - _, err := rand.Read(content) - require.NoError(t, err) - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID - } - { + id := reserveCache(t, testClient, base, strings.ToLower(t.Name()), version, 100) + for _, contentRange := range []string{"bytes xx-99/*", "bytes 0-xx/*"} { req, err := http.NewRequest(http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) + fmt.Sprintf("%s/caches/%d", base, id), nil) require.NoError(t, err) req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes xx-99/*") + req.Header.Set("Content-Range", contentRange) resp, err := testClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -350,96 +250,8 @@ func TestHandler(t *testing.T) { } }) - t.Run("commit with bad id", func(t *testing.T) { - { - resp, err := testClient.Post(base+"/caches/invalid_id", "", nil) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 400, resp.StatusCode) - } - }) - - t.Run("commit with not exist id", func(t *testing.T) { - { - resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 400, resp.StatusCode) - } - }) - - t.Run("duplicate commit", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var id uint64 - content := make([]byte, 100) - _, err := rand.Read(content) - require.NoError(t, err) - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID - } - { - req, err := http.NewRequest(http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("Content-Range", "bytes 0-99/*") - resp, err := testClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - } - { - resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - } - { - resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 400, resp.StatusCode) - } - }) - t.Run("upload write failure returns only error", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var id uint64 - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID - } + id := reserveCache(t, testClient, base, strings.ToLower(t.Name()), version, 100) storageFile := filepath.Join(dir, "not-a-directory") require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600)) @@ -467,30 +279,10 @@ func TestHandler(t *testing.T) { }) t.Run("commit early", func(t *testing.T) { - key := strings.ToLower(t.Name()) - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - var id uint64 content := make([]byte, 100) _, err := rand.Read(content) require.NoError(t, err) - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: 100, - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID - } + id := reserveCache(t, testClient, base, strings.ToLower(t.Name()), version, 100) { req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content[:50])) @@ -524,205 +316,52 @@ func TestHandler(t *testing.T) { require.Equal(t, 404, resp.StatusCode) }) - t.Run("get with multiple keys", func(t *testing.T) { - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - key := strings.ToLower(t.Name()) - keys := [3]string{ - key + "_a_b_c", - key + "_a_b", - key + "_a", - } - contents := [3][]byte{ - make([]byte, 100), - make([]byte, 200), - make([]byte, 300), - } - for i := range contents { - _, err := rand.Read(contents[i]) - require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) - // ensure CreatedAt of caches are different, in upload order - backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second) - } - - reqKeys := strings.Join([]string{ - key + "_a_b_x", - key + "_a_b", - key + "_a", - }, ",") - - resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, 200, resp.StatusCode) - - /* - Expect `key_a_b` because: - - `key_a_b_x" doesn't match any caches. - - `key_a_b" matches `key_a_b` and `key_a_b_c`, but `key_a_b` is newer. - */ - except := 1 - - got := struct { - Result string `json:"result"` - ArchiveLocation string `json:"archiveLocation"` - CacheKey string `json:"cacheKey"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - assert.Equal(t, "hit", got.Result) - assert.Equal(t, keys[except], got.CacheKey) - - contentResp, err := testClient.Get(got.ArchiveLocation) - require.NoError(t, err) - defer contentResp.Body.Close() - require.Equal(t, 200, contentResp.StatusCode) - content, err := io.ReadAll(contentResp.Body) - require.NoError(t, err) - assert.Equal(t, contents[except], content) - }) - - t.Run("case preserved", func(t *testing.T) { - // Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux, - // then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the - // cache was a complete hit. The server must therefore preserve the original key case. - - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - key := strings.ToLower(t.Name()) + "_ABC" - content := make([]byte, 100) - _, err := rand.Read(content) - require.NoError(t, err) - uploadCacheNormally(t, base, key, version, content) - - { - resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) + for _, tc := range []struct { + name string + keys []string + want string + }{ + {"get with multiple keys", []string{"_a_b_x", "_a_b", "_a"}, "_a_b"}, + {"exact keys are preferred (key 0)", []string{"_a", "_a_b"}, "_a"}, + {"exact keys are preferred (key 1)", []string{"_missing", "_a", "_a_b"}, "_a"}, + } { + t.Run(tc.name, func(t *testing.T) { + key := strings.ToLower(t.Name()) + for i, suffix := range []string{"_a", "_a_b_c", "_a_b"} { + uploadCacheNormally(t, base, key+suffix, version, []byte(suffix)) + backdateCache(t, handler, key+suffix, time.Duration(3-i)*time.Second) + } + keys := make([]string, len(tc.keys)) + for i, suffix := range tc.keys { + keys[i] = key + suffix + } + resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, strings.Join(keys, ","), version)) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, 200, resp.StatusCode) - got := struct { + var got struct { Result string `json:"result"` ArchiveLocation string `json:"archiveLocation"` CacheKey string `json:"cacheKey"` - }{} + } require.NoError(t, json.UnmarshalRead(resp.Body, &got)) assert.Equal(t, "hit", got.Result) - assert.Equal(t, key, got.CacheKey) - assert.NotEqual(t, strings.ToLower(key), got.CacheKey) - } - }) - - t.Run("exact keys are preferred (key 0)", func(t *testing.T) { - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - key := strings.ToLower(t.Name()) - keys := [3]string{ - key + "_a", - key + "_a_b_c", - key + "_a_b", - } - contents := [3][]byte{ - make([]byte, 100), - make([]byte, 200), - make([]byte, 300), - } - for i := range contents { - _, err := rand.Read(contents[i]) + assert.Equal(t, key+tc.want, got.CacheKey) + contentResp, err := testClient.Get(got.ArchiveLocation) require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) - // ensure CreatedAt of caches are different, in upload order - backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second) - } - - reqKeys := strings.Join([]string{ - key + "_a", - key + "_a_b", - }, ",") - - resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, 200, resp.StatusCode) - - /* - Expect `key_a` because: - - `key_a` matches `key_a`, `key_a_b` and `key_a_b_c`, but `key_a` is an exact match. - - `key_a_b` matches `key_a_b` and `key_a_b_c`, but previous key had a match - */ - expect := 0 - - got := struct { - ArchiveLocation string `json:"archiveLocation"` - CacheKey string `json:"cacheKey"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - assert.Equal(t, keys[expect], got.CacheKey) - - contentResp, err := testClient.Get(got.ArchiveLocation) - require.NoError(t, err) - defer contentResp.Body.Close() - require.Equal(t, 200, contentResp.StatusCode) - content, err := io.ReadAll(contentResp.Body) - require.NoError(t, err) - assert.Equal(t, contents[expect], content) - }) - - t.Run("exact keys are preferred (key 1)", func(t *testing.T) { - version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - key := strings.ToLower(t.Name()) - keys := [3]string{ - key + "_a", - key + "_a_b_c", - key + "_a_b", - } - contents := [3][]byte{ - make([]byte, 100), - make([]byte, 200), - make([]byte, 300), - } - for i := range contents { - _, err := rand.Read(contents[i]) + defer contentResp.Body.Close() + require.Equal(t, 200, contentResp.StatusCode) + content, err := io.ReadAll(contentResp.Body) require.NoError(t, err) - uploadCacheNormally(t, base, keys[i], version, contents[i]) - // ensure CreatedAt of caches are different, in upload order - backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second) - } + assert.Equal(t, []byte(tc.want), content) + }) + } - reqKeys := strings.Join([]string{ - "------------------------------------------------------", - key + "_a", - key + "_a_b", - }, ",") - - resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, 200, resp.StatusCode) - - /* - Expect `key_a` because: - - `------------------------------------------------------` doesn't match any caches. - - `key_a` matches `key_a`, `key_a_b` and `key_a_b_c`, but `key_a` is an exact match. - - `key_a_b` matches `key_a_b` and `key_a_b_c`, but previous key had a match - */ - expect := 0 - - got := struct { - ArchiveLocation string `json:"archiveLocation"` - CacheKey string `json:"cacheKey"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - assert.Equal(t, keys[expect], got.CacheKey) - - contentResp, err := testClient.Get(got.ArchiveLocation) - require.NoError(t, err) - defer contentResp.Body.Close() - require.Equal(t, 200, contentResp.StatusCode) - content, err := io.ReadAll(contentResp.Body) - require.NoError(t, err) - assert.Equal(t, contents[expect], content) + t.Run("case preserved", func(t *testing.T) { + uploadCacheNormally(t, base, strings.ToLower(t.Name())+"_ABC", version, []byte("cache content")) }) } -// backdateCache rewrites a cache's CreatedAt. It has one-second resolution, so age-ordering -// tests set it directly instead of sleeping a second between uploads. func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration) { db, err := handler.openDB() require.NoError(t, err) @@ -735,26 +374,24 @@ func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration require.NoError(t, db.save(caches[0])) } -func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { - var id uint64 - { - body, err := json.Marshal(&Request{ - Key: key, - Version: version, - Size: int64(len(content)), - }) - require.NoError(t, err) - resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, 200, resp.StatusCode) - - got := struct { - CacheID uint64 `json:"cacheId"` - }{} - require.NoError(t, json.UnmarshalRead(resp.Body, &got)) - id = got.CacheID +func reserveCache(t *testing.T, client *http.Client, base, key, version string, size int64) uint64 { + t.Helper() + body, err := json.Marshal(&Request{Key: key, Version: version, Size: size}) + require.NoError(t, err) + resp, err := client.Post(base+"/caches", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var reserved struct { + CacheID uint64 `json:"cacheId"` } + require.NoError(t, json.UnmarshalRead(resp.Body, &reserved)) + require.NotZero(t, reserved.CacheID) + return reserved.CacheID +} + +func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) uint64 { + id := reserveCache(t, testClient, base, key, version, int64(len(content))) { req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) @@ -797,6 +434,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte require.NoError(t, err) assert.Equal(t, content, got) } + return id } func TestHandler_gcCache(t *testing.T) { @@ -815,7 +453,6 @@ func TestHandler_gcCache(t *testing.T) { Kept bool }{ { - // should be kept, since it's used recently and not too old. Cache: &Cache{ Key: "test_key_1", Version: "test_version", @@ -826,7 +463,6 @@ func TestHandler_gcCache(t *testing.T) { Kept: true, }, { - // should be removed, since it's not complete and not used for a while. Cache: &Cache{ Key: "test_key_2", Version: "test_version", @@ -837,7 +473,6 @@ func TestHandler_gcCache(t *testing.T) { Kept: false, }, { - // should be removed, since it's not used for a while. Cache: &Cache{ Key: "test_key_3", Version: "test_version", @@ -848,7 +483,6 @@ func TestHandler_gcCache(t *testing.T) { Kept: false, }, { - // should be kept, since age alone does not retire an entry that is still used. Cache: &Cache{ Key: "test_key_3", Version: "test_version", @@ -859,7 +493,6 @@ func TestHandler_gcCache(t *testing.T) { Kept: true, }, { - // should be kept, since it has a newer edition but be used recently. Cache: &Cache{ Key: "test_key_1", Version: "test_version", @@ -870,7 +503,6 @@ func TestHandler_gcCache(t *testing.T) { Kept: true, }, { - // should be removed, since it has a newer edition and not be used recently. Cache: &Cache{ Key: "test_key_1", Version: "test_version", @@ -889,7 +521,7 @@ func TestHandler_gcCache(t *testing.T) { } require.NoError(t, db.Close()) - handler.gcAt = time.Time{} // ensure gcCache will not skip + handler.gcAt = time.Time{} handler.gcCache() db, err = handler.openDB() @@ -907,8 +539,6 @@ func TestHandler_gcCache(t *testing.T) { require.NoError(t, db.Close()) } -// TestHandler_evictPolicy covers the non-default policies; TestHandler_gcCache covers the -// defaults across every pass. func TestHandler_evictPolicy(t *testing.T) { now := time.Now() stale := func(d time.Duration) int64 { return now.Add(-d).Unix() } @@ -967,8 +597,6 @@ func TestHandler_evictPolicy(t *testing.T) { kept: []string{"b_new"}, }, { - // Retention below inUseGrace would otherwise drop an entry whose signed URL a job - // is still holding. name: "a retention shorter than the grace still spares a just-served entry", policy: Policy{Retention: time.Minute}, entries: []*Cache{ @@ -998,18 +626,16 @@ func TestHandler_evictPolicy(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { for _, e := range tc.entries { - e.Complete = true // only completed entries carry a measured size, so only they count + e.Complete = true } handler := newTestHandler(t, tc.policy, tc.entries...) - handler.gcAt = time.Time{} // ensure gcCache will not skip + handler.gcAt = time.Time{} handler.gcCache() assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, tc.entries)) }) } } -// TestHandler_evictForFreeSpace proves the volume backstop sheds only what it must, and only -// when the disk is actually short. func TestHandler_evictForFreeSpace(t *testing.T) { free := func(n int64) func(string) (uint64, error) { return func(string) (uint64, error) { return uint64(n), nil } @@ -1049,13 +675,10 @@ func TestHandler_evictForFreeSpace(t *testing.T) { } } -// TestHandler_SweepKeepsEntryWhenBlobSurvives proves a failed unlink leaves the row in place, -// so the next sweep retries rather than orphaning bytes no row points at and no limit counts. func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) { cache := &Cache{Key: "stuck", Complete: true, UsedAt: time.Now().Add(-(testRetention + time.Hour)).Unix()} handler := newTestHandler(t, Policy{Retention: testRetention}, cache) - // A non-empty directory where the blob belongs makes os.Remove fail on every platform. blob := handler.storage.(*Storage).filename(cache.ID) require.NoError(t, os.MkdirAll(blob, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600)) @@ -1066,24 +689,18 @@ func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) { assert.Equal(t, []string{"stuck"}, keptKeys(t, handler, []*Cache{cache}), "the entry must outlive a blob that could not be removed") } -// TestHandler_FindProtectsFromEviction covers the window between a find handing out a signed -// download URL and the GET that redeems it: the entry promised to a job must not be the next -// eviction victim just because its last access predates the find. func TestHandler_FindProtectsFromEviction(t *testing.T) { - // 12 MiB against a 10 MiB limit, so exactly one entry has to go. wanted := &Cache{Repo: testRepo, Key: "wanted", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-3 * time.Hour).Unix()} other := &Cache{Repo: testRepo, Key: "other", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-2 * time.Hour).Unix()} newest := &Cache{Repo: testRepo, Key: "newest", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()} handler := newTestHandler(t, Policy{RepoSizeLimit: 10 * miB}, wanted, other, newest) - writeBlob(t, handler, wanted.ID) // find only reports a hit when the blob is on disk + writeBlob(t, handler, wanted.ID) resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=wanted&version=v", handler.ExternalURL(), apiPath)) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, 200, resp.StatusCode) - // Evict directly: the request above kicked off an async gcCache, and writing gcAt here - // to drive gcCache would race its read. db, err := handler.openDB() require.NoError(t, err) handler.evictOversized(db) @@ -1092,14 +709,10 @@ func TestHandler_FindProtectsFromEviction(t *testing.T) { assert.Equal(t, []string{wanted.Key}, keptKeys(t, handler, []*Cache{wanted, other}), "the entry just promised to a job survives, the next least recently used goes instead") } -// TestHandler_evictOnCommit proves a repository that goes over its limit gets space back at -// once, rather than waiting out the collection interval. func TestHandler_evictOnCommit(t *testing.T) { full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()} handler := newTestHandler(t, Policy{RepoSizeLimit: 4 * miB}, full) - // StartHandler already stamped gcAt, so the periodic sweep stays rate-limited out and - // only the commit path can evict. uploadCacheNormally(t, handler.ExternalURL()+apiPath, "new", "v", []byte("some content")) assert.Empty(t, keptKeys(t, handler, []*Cache{full})) @@ -1107,10 +720,9 @@ func TestHandler_evictOnCommit(t *testing.T) { func TestHandler_gcCacheInterval(t *testing.T) { cache := &Cache{Key: "temp", UsedAt: time.Now().Add(-time.Hour).Unix()} - // Half the default, so a sweep 45m ago is still inside the default but past this one. handler := newTestHandler(t, Policy{SweepInterval: 30 * time.Minute}, cache) - handler.gcAt = time.Now().Add(-45 * time.Minute) // past the configured interval, still inside the default + handler.gcAt = time.Now().Add(-45 * time.Minute) handler.gcCache() assert.Empty(t, keptKeys(t, handler, []*Cache{cache})) } @@ -1135,7 +747,6 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) { assert.Greater(t, txID(), before) } -// newTestHandler starts a handler with testToken registered, seeded with entries. func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler { t.Helper() handler := startTestHandler(t, Options{Policy: policy}) @@ -1160,7 +771,6 @@ func startTestHandler(t *testing.T, opts Options) *Handler { return handler } -// keptKeys reports which of entries are still in the store. func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string { t.Helper() db, err := handler.openDB() @@ -1176,7 +786,6 @@ func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string { return kept } -// writeBlob gives an entry the on-disk bytes that find and get require. func writeBlob(t *testing.T, handler *Handler, id uint64) { t.Helper() require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a"))) @@ -1184,9 +793,7 @@ func writeBlob(t *testing.T, handler *Handler, id uint64) { require.NoError(t, err) } -// TestHandler_RejectsMissingBearer covers the advisory's root cause: -// unauthenticated access to management endpoints is now refused with 401. -func TestHandler_RejectsMissingBearer(t *testing.T) { +func TestHandler_RejectsBearer(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) require.NoError(t, err) @@ -1199,16 +806,21 @@ func TestHandler_RejectsMissingBearer(t *testing.T) { method string path string body string + token string }{ - {"find", http.MethodGet, "/cache?keys=x&version=y", ""}, - {"reserve", http.MethodPost, "/caches", "{}"}, - {"upload", http.MethodPatch, "/caches/1", ""}, - {"commit", http.MethodPost, "/caches/1", ""}, - {"clean", http.MethodPost, "/clean", ""}, + {"find without bearer", http.MethodGet, "/cache?keys=x&version=y", "", ""}, + {"reserve without bearer", http.MethodPost, "/caches", "{}", ""}, + {"upload without bearer", http.MethodPatch, "/caches/1", "", ""}, + {"commit without bearer", http.MethodPost, "/caches/1", "", ""}, + {"clean without bearer", http.MethodPost, "/clean", "", ""}, + {"find with unknown bearer", http.MethodGet, "/cache?keys=x&version=y", "", "not-a-registered-token"}, } { t.Run(tc.name, func(t *testing.T) { req, err := http.NewRequest(tc.method, base+tc.path, strings.NewReader(tc.body)) require.NoError(t, err) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } resp, err := http.DefaultClient.Do(req) require.NoError(t, err) resp.Body.Close() @@ -1217,59 +829,47 @@ func TestHandler_RejectsMissingBearer(t *testing.T) { } } -// TestHandler_RejectsUnknownBearer verifies that a bearer token is only -// accepted after RegisterJob; stale/forged tokens cannot be replayed. -func TestHandler_RejectsUnknownBearer(t *testing.T) { - dir := filepath.Join(t.TempDir(), "artifactcache") - handler, err := StartHandler(Options{Dir: dir}) - require.NoError(t, err) - defer handler.Close() - - base := handler.ExternalURL() + apiPath - - req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer not-a-registered-token") - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -} - -// TestHandler_UnregisterRevokes ensures that the function returned by -// RegisterJob invalidates the credential, so a token leaked at job time stops -// working the moment the job ends instead of living for the runner's lifetime. func TestHandler_UnregisterRevokes(t *testing.T) { - dir := filepath.Join(t.TempDir(), "artifactcache") - handler, err := StartHandler(Options{Dir: dir}) - require.NoError(t, err) - defer handler.Close() + for _, tc := range []struct { + name string + token string + version string + registrations int + }{ + {"single registration", "tmp-token", "y", 1}, + {"reference counted", "shared", "v", 2}, + } { + t.Run(tc.name, func(t *testing.T) { + handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")}) + require.NoError(t, err) + defer handler.Close() + revokers := make([]func(), tc.registrations) + for i := range revokers { + revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo}) + } + probe := func() int { + req, err := http.NewRequest(http.MethodGet, handler.ExternalURL()+apiPath+"/cache?keys=x&version="+tc.version, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tc.token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + return resp.StatusCode + } - unregister := handler.RegisterJob("tmp-token", JobCredential{Repo: testRepo}) - - base := handler.ExternalURL() + apiPath - req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer tmp-token") - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode) - - unregister() - - resp, err = http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.NotEqual(t, http.StatusUnauthorized, probe()) + for i, revoke := range revokers { + revoke() + if i == len(revokers)-1 { + assert.Equal(t, http.StatusUnauthorized, probe(), "token is revoked only after every revoker has run") + } else { + assert.NotEqual(t, http.StatusUnauthorized, probe(), "token must stay valid while another registration holds the refcount") + } + } + }) + } } -// TestHandler_CrossRepoIsolation addresses the intra-runner poisoning vector -// raised in GHSA-82g9-637c-2fx2: job containers can reach the cache server -// over the docker bridge, so IP allowlisting alone does not stop a malicious -// PR run from another repo. A cache entry created under repoA must be -// invisible to queries scoped to repoB. func TestHandler_CrossRepoIsolation(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) @@ -1286,45 +886,32 @@ func TestHandler_CrossRepoIsolation(t *testing.T) { clientA := &http.Client{Transport: &bearerTransport{token: "token-a"}} clientB := &http.Client{Transport: &bearerTransport{token: "token-b"}} - // repoA reserves + uploads + commits. - reserveBody, err := json.Marshal(&Request{Key: key, Version: version, Size: int64(len(content))}) - require.NoError(t, err) - resp, err := clientA.Post(base+"/caches", "application/json", bytes.NewReader(reserveBody)) - require.NoError(t, err) - var reserved struct { - CacheID uint64 `json:"cacheId"` - } - require.NoError(t, json.UnmarshalRead(resp.Body, &reserved)) - resp.Body.Close() - require.NotZero(t, reserved.CacheID) + id := reserveCache(t, clientA, base, key, version, int64(len(content))) - req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(content)) + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content)) require.NoError(t, err) req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1)) - resp, err = clientA.Do(req) + resp, err := clientA.Do(req) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", nil) + resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - // repoB with a matching key and version must NOT see repoA's cache. resp, err = clientB.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) require.NoError(t, err) resp.Body.Close() assert.Equal(t, http.StatusNoContent, resp.StatusCode) - // repoA still sees its own cache. resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) require.NoError(t, err) resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) - // repoB cannot upload to repoA's reserved id either (forbidden, not 401). - req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader([]byte("poison"))) + req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison"))) require.NoError(t, err) req.Header.Set("Content-Range", "bytes 0-5/*") resp, err = clientB.Do(req) @@ -1333,9 +920,6 @@ func TestHandler_CrossRepoIsolation(t *testing.T) { assert.Equal(t, http.StatusForbidden, resp.StatusCode) } -// TestHandler_ArtifactSignature verifies that archive downloads reject -// missing / tampered / expired signatures, so a leaked archiveLocation stops -// working after artifactURLTTL even if the bearer token is still registered. func TestHandler_ArtifactSignature(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) @@ -1344,52 +928,29 @@ func TestHandler_ArtifactSignature(t *testing.T) { handler.RegisterJob(testToken, JobCredential{Repo: testRepo}) base := handler.ExternalURL() + apiPath + good := signArtifactURL(handler, 1) + other, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache2")}) + require.NoError(t, err) + defer other.Close() - t.Run("missing signature", func(t *testing.T) { - resp, err := testClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 1)) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("tampered signature", func(t *testing.T) { - good := signArtifactURL(handler, 1) - bad := good[:len(good)-4] + "dead" - resp, err := testClient.Get(bad) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("expired signature", func(t *testing.T) { - expired := handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second)) - resp, err := testClient.Get(expired) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("signature from a different server", func(t *testing.T) { - dir2 := filepath.Join(t.TempDir(), "artifactcache2") - other, err := StartHandler(Options{Dir: dir2}) - require.NoError(t, err) - defer other.Close() - otherURL := signArtifactURL(other, 1) - // Rewrite the host so the request still lands on our handler, but - // the signature was computed with a different secret. - parts := strings.SplitN(otherURL, apiPath, 2) - forged := base + parts[1] - resp, err := testClient.Get(forged) - require.NoError(t, err) - resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) + for _, tc := range []struct { + name string + url string + }{ + {"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)}, + {"tampered signature", good[:len(good)-4] + "dead"}, + {"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))}, + {"signature from a different server", base + strings.SplitN(signArtifactURL(other, 1), apiPath, 2)[1]}, + } { + t.Run(tc.name, func(t *testing.T) { + resp, err := testClient.Get(tc.url) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + } } -// TestHandler_SecretPersistsAcrossRestarts is the property that lets -// gitea-runner cache-server be pointed at via cfg.Cache.ExternalServer: a -// restart must not invalidate signed URLs the handler has already issued -// (within their expiry window). func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") @@ -1423,9 +984,6 @@ func TestHandler_SeesWritesOfOtherProcesses(t *testing.T) { assert.Equal(t, []string{"theirs", "later", "ours"}, keptKeys(t, handler, []*Cache{theirs, later, ours})) } -// TestHandler_ArtifactSignatureDownload is a happy-path round trip that -// ensures a real reserve/upload/commit/find/download flow still works after -// the auth refactor. func TestHandler_ArtifactSignatureDownload(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) @@ -1451,8 +1009,6 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) { require.Contains(t, hit.ArchiveLocation, "sig=") require.Contains(t, hit.ArchiveLocation, "exp=") - // Download without any Authorization header — the signature alone must - // be enough, because @actions/cache downloads archiveLocation unauth'd. dl, err := http.Get(hit.ArchiveLocation) require.NoError(t, err) body, err := io.ReadAll(dl.Body) @@ -1462,42 +1018,6 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) { assert.Equal(t, content, body) } -// TestHandler_RegisterJob_RefCounted verifies that a duplicate RegisterJob -// for the same token does not silently revoke the first registration on the -// first revoker call. This matters if a runner ever re-registers a token -// (restart mid-task, retry), which must not kill the live job's auth. -func TestHandler_RegisterJob_RefCounted(t *testing.T) { - dir := filepath.Join(t.TempDir(), "artifactcache") - handler, err := StartHandler(Options{Dir: dir}) - require.NoError(t, err) - defer handler.Close() - - first := handler.RegisterJob("shared", JobCredential{Repo: testRepo}) - second := handler.RegisterJob("shared", JobCredential{Repo: testRepo}) - - base := handler.ExternalURL() + apiPath - probe := func() int { - req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer shared") - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - return resp.StatusCode - } - - require.NotEqual(t, http.StatusUnauthorized, probe()) - first() - assert.NotEqual(t, http.StatusUnauthorized, probe(), - "token must stay valid while another registration holds the refcount") - second() - assert.Equal(t, http.StatusUnauthorized, probe(), - "token is revoked only after every revoker has run") -} - -// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict -// another repo's entry. Two repos reserve the same (key, version); after the -// inUseGrace window, GC must keep the one from each repo. func TestHandler_GC_PerRepoDedup(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) @@ -1509,8 +1029,6 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) { key := "shared-dedup-key" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" - // Seed one completed cache per repo directly via the DB, bypassing the - // HTTP round trip so we can precisely control UsedAt. db, err := handler.openDB() require.NoError(t, err) now := time.Now().Unix() @@ -1519,7 +1037,6 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) { b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1} require.NoError(t, handler.insertCache(db, a)) require.NoError(t, handler.insertCache(db, b)) - // Write the backing blobs so the dedup deletion has something to remove. require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a"))) _, err = handler.storage.Commit(a.ID, 1) require.NoError(t, err) @@ -1528,7 +1045,6 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) { require.NoError(t, err) require.NoError(t, db.Close()) - // Force GC to run regardless of the cooldown. handler.gcAt = time.Time{} handler.gcCache() @@ -1546,9 +1062,6 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) { assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA") } -// TestHandler_InternalAPI_Disabled verifies that without an internalSecret -// the control-plane routes are 404 — operators can't accidentally hit -// register/revoke when the feature is off. func TestHandler_InternalAPI_Disabled(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := StartHandler(Options{Dir: dir}) @@ -1563,9 +1076,6 @@ func TestHandler_InternalAPI_Disabled(t *testing.T) { } } -// TestHandler_InternalAPI_AuthAndUsage covers the control-plane: bad/missing -// secret → 401, malformed body → 400, happy path round-trips a token through -// register → cache-API accepts it → revoke → cache-API rejects it. func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") const secret = "internal-secret" diff --git a/act/artifactcache/handler_v2_test.go b/act/artifactcache/handler_v2_test.go index a0a3fd6a..97cd254e 100644 --- a/act/artifactcache/handler_v2_test.go +++ b/act/artifactcache/handler_v2_test.go @@ -19,8 +19,6 @@ import ( "github.com/stretchr/testify/require" ) -// v2Call posts a twirp request to the cache service and returns the decoded response. -// Field names are the proto ones, which is what the toolkit's client sends. func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any { t.Helper() @@ -37,7 +35,6 @@ func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, return got } -// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal. func putBlob(t *testing.T, url string, content []byte) int { t.Helper() @@ -47,8 +44,6 @@ func putBlob(t *testing.T, url string, content []byte) int { require.NoError(t, err) defer resp.Body.Close() if resp.StatusCode == http.StatusCreated { - // The Azure SDK client dereferences this header without checking, so a blob upload that - // omits it panics the caller rather than failing it. require.NotEmpty(t, resp.Header.Get("x-ms-request-id")) } return resp.StatusCode @@ -67,8 +62,6 @@ func getURL(t *testing.T, url string) []byte { return body } -// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along -// with the upload URL it used. func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) { t.Helper() @@ -84,9 +77,6 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) }), uploadURL } -// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed -// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read -// or to replace a finalized entry. func TestCacheServiceV2RoundTrip(t *testing.T) { handler := newTestHandler(t, Policy{}) content := []byte("the cached archive") @@ -98,10 +88,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) { require.Equal(t, true, finalized["ok"]) assert.NotEmpty(t, finalized["entry_id"]) - // The upload URL outlives the finalize call, so replaying it must not poison the entry, - // and it is an upload URL only: nothing reads a blob back through it. assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned"))) - resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test + resp, err := http.Get(uploadURL) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) @@ -114,8 +102,6 @@ func TestCacheServiceV2RoundTrip(t *testing.T) { assert.Equal(t, content, getURL(t, downloadURL)) } -// A large archive is staged as blocks and only put in order by the final block list, so -// blocks that arrive out of order must still be assembled the way the client asked. func TestCacheServiceV2BlockUpload(t *testing.T) { handler := newTestHandler(t, Policy{}) @@ -130,7 +116,6 @@ func TestCacheServiceV2BlockUpload(t *testing.T) { blocks[blockID] = []byte(part) order = append(order, blockID) } - // Upload in an order that is not the block list order. for _, blockID := range []string{order[2], order[0], order[1]} { require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID])) } @@ -174,8 +159,6 @@ func TestCacheServiceV2Lookups(t *testing.T) { assert.Equal(t, false, got["ok"]) }) - // The toolkit serialises with the proto field names; the camelCase spellings of the same - // proto JSON mapping are accepted alongside them. for _, field := range []string{"restore_keys", "restoreKeys"} { t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) { got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{ @@ -191,9 +174,6 @@ func TestCacheServiceV2Lookups(t *testing.T) { assert.Equal(t, false, again["ok"]) }) - // A key that is only a prefix of an existing one is a different entry, so the - // reservation check must be exact and not a restore-key prefix match, or the shorter - // key would be reported as existing and silently never saved. t.Run("a prefix of an existing key is still reserved", func(t *testing.T) { reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"}) require.Equal(t, true, reserved["ok"]) @@ -219,7 +199,6 @@ func TestCacheServiceV2Lookups(t *testing.T) { assert.Equal(t, false, got["ok"]) }) - // The size the client declares is what Commit validates the assembled archive against. t.Run("finalizing with the wrong size is not ok", func(t *testing.T) { created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"}) require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four"))) @@ -230,8 +209,6 @@ func TestCacheServiceV2Lookups(t *testing.T) { assert.Equal(t, false, got["ok"]) }) - // Both API versions are served from one store, so an entry written through v2 is a hit for - // a v1 client asking for the same key and version. t.Run("a v1 client sees an entry written through v2", func(t *testing.T) { resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath)) require.NoError(t, err) @@ -244,7 +221,6 @@ func TestCacheServiceV2Lookups(t *testing.T) { assert.NotEmpty(t, got["archiveLocation"]) }) - // The cache of one repository must stay invisible to another, as it does for the v1 API. t.Run("another repository sees nothing", func(t *testing.T) { handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"}) otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}} @@ -321,20 +297,12 @@ func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) { "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() + madeByV1 := reserveCache(t, testClient, base, "v1-made", "v1", int64(len(content))) upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch, - fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content)) + fmt.Sprintf("%s/caches/%d", base, madeByV1), 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) + resp, err := testClient.Do(upload) require.NoError(t, err) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/act/artifactcache/results.go b/act/artifactcache/results.go index 9f6418cd..5cfe22f3 100644 --- a/act/artifactcache/results.go +++ b/act/artifactcache/results.go @@ -4,6 +4,7 @@ package artifactcache import ( + "cmp" "crypto/tls" "errors" "fmt" @@ -74,14 +75,7 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) { // 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 scheme == "" { - scheme = "http" - } - return scheme != target.Scheme + return cred.InsecureTLS && target.Scheme == "https" || cmp.Or(r.Header.Get("X-Forwarded-Proto"), "http") != target.Scheme } func mergeQuery(target, request string) string { diff --git a/act/artifactcache/storage.go b/act/artifactcache/storage.go index 6e015711..e9f7795a 100644 --- a/act/artifactcache/storage.go +++ b/act/artifactcache/storage.go @@ -32,13 +32,11 @@ func NewStorage(rootDir string) (*Storage, error) { } func (s *Storage) Exist(id uint64) (bool, error) { - name := s.filename(id) - if _, err := os.Stat(name); os.IsNotExist(err) { + _, err := os.Stat(s.filename(id)) + if os.IsNotExist(err) { return false, nil - } else if err != nil { - return false, err } - return true, nil + return err == nil, err } func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error { @@ -92,9 +90,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error { } func (s *Storage) Commit(id uint64, size int64) (int64, error) { - defer func() { - _ = os.RemoveAll(s.tempDir(id)) - }() + defer func() { _ = os.RemoveAll(s.tempDir(id)) }() name := s.filename(id) tempNames, err := s.tempNames(id) @@ -153,8 +149,7 @@ func assemble(name string, tempNames []string) (int64, error) { } func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) { - name := s.filename(id) - http.ServeFile(w, r, name) + http.ServeFile(w, r, s.filename(id)) } // Remove deletes an entry's blob and any staged parts. It reports failure so the caller can diff --git a/act/artifacts/server.go b/act/artifacts/server.go index b6f81074..e66af8d9 100644 --- a/act/artifacts/server.go +++ b/act/artifacts/server.go @@ -79,8 +79,7 @@ func uploads(router *http.ServeMux, baseDir string) { itemPath += gzipExtension } - safeRunPath := safeResolve(baseDir, req.PathValue("runId")) - safePath := safeResolve(safeRunPath, itemPath) + safePath := safeResolve(safeResolve(baseDir, req.PathValue("runId")), itemPath) if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil { panic(err) @@ -120,9 +119,7 @@ func downloads(router *http.ServeMux, baseDir string) { router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) { runID := req.PathValue("runId") - safePath := safeResolve(baseDir, runID) - - entries, err := os.ReadDir(safePath) + entries, err := os.ReadDir(safeResolve(baseDir, runID)) if err != nil { panic(err) } @@ -156,15 +153,11 @@ func downloads(router *http.ServeMux, baseDir string) { // if it was upload as gzip rel = strings.TrimSuffix(rel, gzipExtension) - path := filepath.Join(itemPath, rel) - - rel = filepath.ToSlash(rel) - path = filepath.ToSlash(path) files = append(files, ContainerItem{ - Path: path, + Path: filepath.ToSlash(filepath.Join(itemPath, rel)), ItemType: "file", - ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel), + ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, filepath.ToSlash(rel)), }) } return nil diff --git a/act/common/dryrun.go b/act/common/dryrun.go index 1a60a6bf..1df7384c 100644 --- a/act/common/dryrun.go +++ b/act/common/dryrun.go @@ -14,13 +14,8 @@ const dryrunContextKeyVal = dryrunContextKey("dryrun") // Dryrun returns true if the current context is dryrun func Dryrun(ctx context.Context) bool { - val := ctx.Value(dryrunContextKeyVal) - if val != nil { - if dryrun, ok := val.(bool); ok { - return dryrun - } - } - return false + dryrun, _ := ctx.Value(dryrunContextKeyVal).(bool) + return dryrun } // WithDryrun adds a value to the context for dryrun diff --git a/act/common/executor.go b/act/common/executor.go index 045e27b5..255cc250 100644 --- a/act/common/executor.go +++ b/act/common/executor.go @@ -21,8 +21,7 @@ type Conditional func(ctx context.Context) bool // NewInfoExecutor is an executor that logs messages func NewInfoExecutor(format string, args ...any) Executor { return func(ctx context.Context) error { - logger := Logger(ctx) - logger.Infof(format, args...) + Logger(ctx).Infof(format, args...) return nil } } @@ -30,8 +29,7 @@ func NewInfoExecutor(format string, args ...any) Executor { // NewDebugExecutor is an executor that logs messages func NewDebugExecutor(format string, args ...any) Executor { return func(ctx context.Context) error { - logger := Logger(ctx) - logger.Debugf(format, args...) + Logger(ctx).Debugf(format, args...) return nil } } @@ -39,9 +37,7 @@ func NewDebugExecutor(format string, args ...any) Executor { // NewPipelineExecutor creates a new executor from a series of other executors func NewPipelineExecutor(executors ...Executor) Executor { if len(executors) == 0 { - return func(ctx context.Context) error { - return nil - } + return NewErrorExecutor(nil) } var rtn Executor for _, executor := range executors { @@ -87,8 +83,8 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor { logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors)) - for i := 0; i < parallel; i++ { - go func(workerID int, work <-chan Executor, errs chan<- error) { + for workerID := range parallel { + go func() { logging.Debugf("Worker %d started", workerID) taskCount := 0 for executor := range work { @@ -103,7 +99,7 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor { }() } logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount) - }(i, work, errs) + }() } for i := range executors { @@ -152,12 +148,7 @@ func (e Executor) If(conditional Conditional) Executor { // IfNot only runs this executor if conditional is true func (e Executor) IfNot(conditional Conditional) Executor { - return func(ctx context.Context) error { - if !conditional(ctx) { - return e(ctx) - } - return nil - } + return e.If(func(ctx context.Context) bool { return !conditional(ctx) }) } // IfBool only runs this executor if conditional is true diff --git a/act/common/executor_test.go b/act/common/executor_test.go index 46c8efa2..04153187 100644 --- a/act/common/executor_test.go +++ b/act/common/executor_test.go @@ -7,8 +7,6 @@ package common import ( "context" "errors" - "reflect" - "strings" "sync" "sync/atomic" "testing" @@ -18,42 +16,25 @@ import ( ) func TestNewWorkflow(t *testing.T) { - assert := assert.New(t) - ctx := t.Context() + require.NoError(t, NewPipelineExecutor()(ctx)) + require.EqualError(t, NewErrorExecutor(errors.New("test error"))(ctx), "test error") - // empty - emptyWorkflow := NewPipelineExecutor() - assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act - - // error case - errorWorkflow := NewErrorExecutor(errors.New("test error")) - assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act - - // multiple success case runcount := 0 - successWorkflow := NewPipelineExecutor( - func(ctx context.Context) error { - runcount++ - return nil - }, - func(ctx context.Context) error { - runcount++ - return nil - }) - assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act - assert.Equal(2, runcount) + increment := func(_ctx context.Context) error { + runcount++ + return nil + } + require.NoError(t, NewPipelineExecutor(increment, increment)(ctx)) + assert.Equal(t, 2, runcount) } -// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies -// block until wantActive are in flight so the peak is exact without sleeping, and later copies -// find the gate already open so the last one still finishes with no partner left. func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) { var counted, active, peak atomic.Int32 var once sync.Once reached := make(chan struct{}) - return func(ctx context.Context) error { + return func(_ctx context.Context) error { counted.Add(1) running := active.Add(1) for { @@ -72,18 +53,14 @@ func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic } func TestNewParallelExecutor(t *testing.T) { - ctx := t.Context() - - exec, count, maxActive := concurrencyProbe(2) - require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx)) - assert.Equal(t, int32(3), count.Load(), "should run all 3 executors") - assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel") - - // parallelism below 1 falls back to a single worker - exec, count, maxActive = concurrencyProbe(1) - require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx)) - assert.Equal(t, int32(3), count.Load(), "should run all 3 executors") - assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel") + for name, parallel := range map[string]int{"parallel": 2, "below minimum": 0} { + t.Run(name, func(t *testing.T) { + exec, count, maxActive := concurrencyProbe(int32(max(1, parallel))) + require.NoError(t, NewParallelExecutor(parallel, exec, exec, exec)(t.Context())) + assert.Equal(t, int32(3), count.Load()) + assert.Equal(t, int32(max(1, parallel)), maxActive.Load()) + }) + } } func TestNewParallelExecutorEmpty(t *testing.T) { @@ -99,66 +76,49 @@ func TestNewParallelExecutorEmpty(t *testing.T) { assert.ErrorIs(err, context.Canceled) } -func TestNewParallelExecutorFailed(t *testing.T) { - assert := assert.New(t) - - ctx, cancel := context.WithCancel(t.Context()) - cancel() - - count := 0 - errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error { - count++ - return errors.New("fake error") - }) - err := NewParallelExecutor(1, errorWorkflow)(ctx) - assert.Equal(1, count) - assert.ErrorIs(context.Canceled, err) -} - -func TestNewParallelExecutorCanceled(t *testing.T) { - assert := assert.New(t) - - ctx, cancel := context.WithCancel(t.Context()) - cancel() - - errExpected := errors.New("fake error") - - var count atomic.Int32 - successWorkflow := NewPipelineExecutor(func(ctx context.Context) error { - count.Add(1) - return nil - }) - errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error { - count.Add(1) - return errExpected - }) - err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx) - assert.Equal(int32(3), count.Load()) - assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act -} - -func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) { - var successCount atomic.Int32 - executors := make([]Executor, 5) - for i := range executors { - executors[i] = func(ctx context.Context) error { - if i == 2 { - return errors.New("fake error") +func TestNewParallelExecutorRunsAllTasksAfterFailureOrCancellation(t *testing.T) { + for name, tc := range map[string]struct { + canceled bool + parallel, tasks int + }{ + "failure": {false, 2, 5}, + "canceled": {true, 2, 5}, + "canceled single task": {true, 1, 1}, + } { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if tc.canceled { + cancel() + } + var calls atomic.Int32 + executors := make([]Executor, tc.tasks) + for i := range executors { + executors[i] = func(_ctx context.Context) error { + calls.Add(1) + if i == tc.tasks/2 { + return errors.New("fake error") + } + return nil + } } - successCount.Add(1) - return nil - } - } - require.Error(t, NewParallelExecutor(2, executors...)(t.Context())) - assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others") + err := NewParallelExecutor(tc.parallel, executors...)(ctx) + if tc.canceled { + require.ErrorIs(t, err, context.Canceled) + } else { + require.EqualError(t, err, "fake error") + } + assert.Equal(t, int32(tc.tasks), calls.Load()) + }) + } } func TestExecutorConditionalsAndFinally(t *testing.T) { ctx := t.Context() var calls []string record := func(name string) Executor { - return func(ctx context.Context) error { + return func(_ctx context.Context) error { calls = append(calls, name) return nil } @@ -170,10 +130,7 @@ func TestExecutorConditionalsAndFinally(t *testing.T) { require.NoError(t, record("if-bool").IfBool(true)(ctx)) require.NoError(t, record("main").Finally(record("finally"))(ctx)) - want := []string{"if-true", "if-not", "if-bool", "main", "finally"} - if !reflect.DeepEqual(calls, want) { - t.Fatalf("calls = %v, want %v", calls, want) - } + assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls) } func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) { @@ -181,8 +138,5 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) { finalErr := errors.New("cleanup failed") err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context()) - require.Error(t, err) - if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") { - t.Fatalf("finally error = %q, want both cleanup and original error", err) - } + require.EqualError(t, err, "error occurred running finally: cleanup failed (original error: main failed)") } diff --git a/act/common/git/git.go b/act/common/git/git.go index 214c32b8..66994c01 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -79,25 +79,25 @@ func (e *Error) Unwrap() error { return e.err } func (e *Error) Commit() string { return e.commit } // FindGitRevision get the current git revision -func FindGitRevision(ctx context.Context, file string) (shortSHA, sha string, err error) { +func FindGitRevision(ctx context.Context, file string) (string, error) { logger := common.Logger(ctx) - sha, err = gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}") + sha, err := gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { logger.WithError(err).Error("path", file, "not located inside a git repository") - return "", "", err + return "", err } if len(sha) < 7 { - return "", "", errors.New("HEAD could not be resolved") + return "", errors.New("HEAD could not be resolved") } logger.Debugf("Found revision: %s", sha) - return sha[:7], sha, nil + return sha, nil } // FindGitRef get the current git ref func FindGitRef(ctx context.Context, file string) (string, error) { logger := common.Logger(ctx) logger.Debugf("Loading revision from git directory") - _, revision, err := FindGitRevision(ctx, file) + revision, err := FindGitRevision(ctx, file) if err != nil { return "", err } diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 789ab2c0..b3d776ef 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -67,42 +67,20 @@ func TestErrorWrapsCommitAndCause(t *testing.T) { require.Equal(t, "abc123", err.Commit()) } -func cleanGitHooks(dir string) error { - hooksDir := filepath.Join(dir, ".git", "hooks") - files, err := os.ReadDir(hooksDir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - for _, f := range files { - if f.IsDir() { - continue - } - relName := filepath.Join(hooksDir, f.Name()) - if err := os.Remove(relName); err != nil { - return err - } - } - return nil -} - func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) { t.Parallel() basedir := t.TempDir() const remoteURL = "https://github.com/owner/repo.git" require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir)) - require.NoError(t, cleanGitHooks(basedir)) + require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks"))) require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init")) require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)) require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git")) - shortSHA, sha, err := FindGitRevision(t.Context(), basedir) + sha, err := FindGitRevision(t.Context(), basedir) require.NoError(t, err) require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha) require.Len(t, sha, 64) - require.Equal(t, sha[:7], shortSHA) ref, err := FindGitRef(t.Context(), basedir) require.NoError(t, err) @@ -122,89 +100,66 @@ func TestGitFindRef(t *testing.T) { basedir := t.TempDir() for name, tt := range map[string]struct { - Prepare func(t *testing.T, dir string) - Assert func(t *testing.T, ref string, err error) + commands [][]string + ref string }{ - "new_repo": { - Prepare: func(t *testing.T, dir string) {}, - Assert: func(t *testing.T, ref string, err error) { - require.Error(t, err) - }, - }, + "new_repo": {}, "new_repo_with_commit": { - Prepare: func(t *testing.T, dir string) { - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) - }, - Assert: func(t *testing.T, ref string, err error) { - require.NoError(t, err) - require.Equal(t, "refs/heads/master", ref) - }, + commands: [][]string{{"commit", "--allow-empty", "-m", "msg"}}, + ref: "refs/heads/master", }, "current_head_is_tag": { - Prepare: func(t *testing.T, dir string) { - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg")) - require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3")) - require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3")) - }, - Assert: func(t *testing.T, ref string, err error) { - require.NoError(t, err) - require.Equal(t, "refs/tags/v1.2.3", ref) + commands: [][]string{ + {"commit", "--allow-empty", "-m", "commit msg"}, + {"tag", "v1.2.3"}, + {"checkout", "v1.2.3"}, }, + ref: "refs/tags/v1.2.3", }, "current_head_is_same_as_tag": { - Prepare: func(t *testing.T, dir string) { - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release")) - require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) - }, - Assert: func(t *testing.T, ref string, err error) { - require.NoError(t, err) - require.Equal(t, "refs/tags/v1.4.2", ref) + commands: [][]string{ + {"commit", "--allow-empty", "-m", "1.4.2 release"}, + {"tag", "v1.4.2"}, }, + ref: "refs/tags/v1.4.2", }, "current_head_is_not_tag": { - Prepare: func(t *testing.T, dir string) { - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) - require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2")) - }, - Assert: func(t *testing.T, ref string, err error) { - require.NoError(t, err) - require.Equal(t, "refs/heads/master", ref) + commands: [][]string{ + {"commit", "--allow-empty", "-m", "msg"}, + {"tag", "v1.4.2"}, + {"commit", "--allow-empty", "-m", "msg2"}, }, + ref: "refs/heads/master", }, "current_head_is_another_branch": { - Prepare: func(t *testing.T, dir string) { - require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch")) - require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) - }, - Assert: func(t *testing.T, ref string, err error) { - require.NoError(t, err) - require.Equal(t, "refs/heads/mybranch", ref) + commands: [][]string{ + {"checkout", "-b", "mybranch"}, + {"commit", "--allow-empty", "-m", "msg"}, }, + ref: "refs/heads/mybranch", }, } { t.Run(name, func(t *testing.T) { dir := filepath.Join(basedir, name) require.NoError(t, os.MkdirAll(dir, 0o755)) require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master")) - require.NoError(t, cleanGitHooks(dir)) - tt.Prepare(t, dir) + require.NoError(t, os.RemoveAll(filepath.Join(dir, ".git", "hooks"))) + for _, command := range tt.commands { + require.NoError(t, gitCmd(append([]string{"-C", dir}, command...)...)) + } ref, err := FindGitRef(t.Context(), dir) - tt.Assert(t, ref, err) + if tt.ref == "" { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.ref, ref) + } }) } } func TestGitCloneExecutor(t *testing.T) { - // Build a local bare "remote" so this runs offline and fast. The cases below mirror - // the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by - // cloning actions/checkout and anchore/scan-action over the network. - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) + remoteDir, workDir := newGitRemote(t) require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755)) require.NoError(t, gitCmd("-C", workDir, "add", "action.sh")) require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial")) @@ -212,14 +167,11 @@ func TestGitCloneExecutor(t *testing.T) { require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2")) - // A branch with a dash in the name (mirrors the historical scan-action@act-fails case). require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails")) - out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output() - require.NoError(t, err) - fullSha := strings.TrimSpace(string(out)) + fullSha := gitRevParse(t, workDir, "main") require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4])) @@ -265,12 +217,12 @@ func TestGitCloneExecutor(t *testing.T) { err := clone(t.Context()) if tt.Err != nil { - assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.Error(t, err) assert.Equal(t, tt.Err, err) assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error") return } - assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) marker := filepath.Join(dir, "marker") require.NoError(t, os.WriteFile(marker, nil, 0o644)) require.NoError(t, clone(t.Context())) @@ -285,13 +237,7 @@ func TestGitCloneExecutor(t *testing.T) { func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) { t.Parallel() createRemote := func(message string) string { - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message)) + remoteDir, workDir := newGitRemote(t, message) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) return remoteDir @@ -341,13 +287,7 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) { func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) { t.Parallel() - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1")) + remoteDir, workDir := newGitRemote(t, "initial", "feature-1") require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) dir := t.TempDir() @@ -380,16 +320,9 @@ func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) { func TestGitCloneExecutorOfflineMode(t *testing.T) { t.Parallel() - // Build a local "remote" with a single commit on main. - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) + remoteDir, workDir := newGitRemote(t, "initial") require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) - // Prime the cache with an online clone of main. cacheDir := t.TempDir() require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ URL: remoteDir, @@ -398,8 +331,6 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) { })(t.Context())) t.Run("cached branch resolves without fetching", func(t *testing.T) { - // Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref) - // finds no local refs/heads/. err := NewGitCloneExecutor(NewGitCloneExecutorInput{ URL: remoteDir, Ref: "main", @@ -414,7 +345,6 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) { }) t.Run("unresolvable cached ref returns error", func(t *testing.T) { - // The ref was never cached; offline mode cannot resolve it and must return an error. err := NewGitCloneExecutor(NewGitCloneExecutorInput{ URL: remoteDir, Ref: "never-fetched", @@ -427,15 +357,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) { func TestGitCloneExecutorShallow(t *testing.T) { t.Parallel() - // Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one. - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) - for _, m := range []string{"c1", "c2", "c3"} { - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m)) - } + remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3") require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1") require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) @@ -443,25 +365,17 @@ func TestGitCloneExecutorShallow(t *testing.T) { shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") } - t.Run("branch is cloned shallowly", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ - URL: remoteDir, Ref: "main", Dir: dir, Depth: 1, - })(t.Context())) - assert.FileExists(t, shallowMarker(dir), "clone should be shallow") - assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present") - assert.Equal(t, "c3", gitHeadSubject(t, dir)) - }) - - t.Run("tag is cloned shallowly", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ - URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1, - })(t.Context())) - assert.FileExists(t, shallowMarker(dir), "clone should be shallow") - assert.Equal(t, 1, gitRevCount(t, dir)) - assert.Equal(t, "c3", gitHeadSubject(t, dir)) - }) + for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, Ref: ref, Dir: dir, Depth: 1, + })(t.Context())) + assert.FileExists(t, shallowMarker(dir), "clone should be shallow") + assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present") + assert.Equal(t, "c3", gitHeadSubject(t, dir)) + }) + } unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string { remote := filepath.Join(t.TempDir(), "remote.git") @@ -541,12 +455,7 @@ func TestGitCloneExecutorShallow(t *testing.T) { func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) { t.Parallel() - remoteDir := t.TempDir() - require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) - workDir := t.TempDir() - require.NoError(t, gitCmd("clone", remoteDir, workDir)) - require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) - require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned")) + remoteDir, workDir := newGitRemote(t, "pinned") pinned := gitRevParse(t, workDir, "HEAD") require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy")) require.NoError(t, gitCmd("-C", workDir, "tag", pinned)) @@ -564,6 +473,18 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) { } } +func newGitRemote(t *testing.T, messages ...string) (string, string) { + t.Helper() + remoteDir, workDir := t.TempDir(), t.TempDir() + require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) + require.NoError(t, gitCmd("clone", remoteDir, workDir)) + require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) + for _, message := range messages { + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message)) + } + return remoteDir, workDir +} + func gitRevParse(t *testing.T, dir, rev string) string { t.Helper() out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output() @@ -591,9 +512,6 @@ func gitCmd(args ...string) error { cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - // Inject a deterministic identity and ignore the host's global/system config so commits - // succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS - // unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig. cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Unit Test", "GIT_AUTHOR_EMAIL=test@test.com", @@ -695,7 +613,6 @@ func TestAcquireCloneLock(t *testing.T) { }) } -// An unresponsive remote must not pin a job: the refresh has to be interruptible. func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) { t.Parallel() block := make(chan struct{}) diff --git a/act/common/job_error.go b/act/common/job_error.go index 5ec617c8..4ee769f8 100644 --- a/act/common/job_error.go +++ b/act/common/job_error.go @@ -14,13 +14,8 @@ const jobErrorContextKeyVal = jobErrorContextKey("job.error") // JobError returns the job error for current context if any func JobError(ctx context.Context) error { - val := ctx.Value(jobErrorContextKeyVal) - if val != nil { - if container, ok := val.(map[string]error); ok { - return container["error"] - } - } - return nil + container, _ := ctx.Value(jobErrorContextKeyVal).(map[string]error) + return container["error"] } func SetJobError(ctx context.Context, err error) { @@ -31,6 +26,5 @@ func SetJobError(ctx context.Context, err error) { // WithJobErrorContainer adds a value to the context as a container for an error func WithJobErrorContainer(ctx context.Context) context.Context { - container := map[string]error{} - return context.WithValue(ctx, jobErrorContextKeyVal, container) + return context.WithValue(ctx, jobErrorContextKeyVal, map[string]error{}) } diff --git a/act/common/line_writer.go b/act/common/line_writer.go index 71a6b9d6..5f1216be 100644 --- a/act/common/line_writer.go +++ b/act/common/line_writer.go @@ -26,9 +26,7 @@ type lineWriter struct { // NewLineWriter creates a new instance of a line writer func NewLineWriter(handlers ...LineHandler) io.Writer { - w := new(lineWriter) - w.handlers = handlers - return w + return &lineWriter{handlers: handlers} } // FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so @@ -39,24 +37,15 @@ func FlushWriter(w io.Writer) { } } -func (lw *lineWriter) Write(p []byte) (n int, err error) { - pBuf := bytes.NewBuffer(p) - written := 0 - for { - line, err := pBuf.ReadString('\n') - w, _ := lw.buffer.WriteString(line) - written += w - if err != nil { - if err == io.EOF { - break - } - return written, err +func (lw *lineWriter) Write(p []byte) (int, error) { + for line := range bytes.SplitAfterSeq(p, []byte{'\n'}) { + _, _ = lw.buffer.Write(line) + if len(line) > 0 && line[len(line)-1] == '\n' { + lw.handleLine(lw.buffer.String()) + lw.buffer.Reset() } - lw.handleLine(lw.buffer.String()) - lw.buffer.Reset() } - - return written, nil + return len(p), nil } // Flush emits any buffered, not-yet-newline-terminated content as a final line. @@ -72,8 +61,7 @@ func (lw *lineWriter) Flush() { func (lw *lineWriter) handleLine(line string) { for _, h := range lw.handlers { - ok := h(line) - if !ok { + if !h(line) { break } } diff --git a/act/common/line_writer_test.go b/act/common/line_writer_test.go index f58d319d..6ccedf34 100644 --- a/act/common/line_writer_test.go +++ b/act/common/line_writer_test.go @@ -9,24 +9,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestLineWriter(t *testing.T) { - lines := make([]string, 0) - lineHandler := func(s string) bool { +func TestLineWriterBuffersAndFlushesLines(t *testing.T) { + var lines []string + lineWriter := NewLineWriter(func(s string) bool { lines = append(lines, s) return true - } - - lineWriter := NewLineWriter(lineHandler) + }) assert := assert.New(t) write := func(s string) { n, err := lineWriter.Write([]byte(s)) - assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) assert.Equal(len(s), n, s) } + write("") write("hello") write(" ") write("world!!\nextra") @@ -34,39 +34,17 @@ func TestLineWriter(t *testing.T) { write(" line\n") write("no newline here...") - assert.Len(lines, 4) - assert.Equal("hello world!!\n", lines[0]) - assert.Equal("extra line\n", lines[1]) - assert.Equal(" and another\n", lines[2]) - assert.Equal("last line\n", lines[3]) -} + expected := []string{"hello world!!\n", "extra line\n", " and another\n", "last line\n"} + assert.Equal(expected, lines) -func TestLineWriterFlush(t *testing.T) { - lines := make([]string, 0) - lineHandler := func(s string) bool { - lines = append(lines, s) - return true - } - - lineWriter := NewLineWriter(lineHandler) - - assert := assert.New(t) - _, err := lineWriter.Write([]byte("complete line\npartial line without newline")) - assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act - - // Only the newline-terminated line is emitted before flushing. - assert.Equal([]string{"complete line\n"}, lines) - - // Flushing emits the buffered, not-yet-terminated trailing line. FlushWriter(lineWriter) - assert.Equal([]string{"complete line\n", "partial line without newline"}, lines) + expected = append(expected, "no newline here...") + assert.Equal(expected, lines) - // Flushing again is a no-op: nothing is buffered. FlushWriter(lineWriter) - assert.Len(lines, 2) + assert.Equal(expected, lines) } func TestFlushWriterIgnoresNonFlusher(t *testing.T) { - // FlushWriter must be a safe no-op for writers that do not buffer lines. assert.NotPanics(t, func() { FlushWriter(io.Discard) }) } diff --git a/act/common/logger.go b/act/common/logger.go index 14e5ba2b..bbfbc8d2 100644 --- a/act/common/logger.go +++ b/act/common/logger.go @@ -34,10 +34,8 @@ const loggerHandlerKeyVal = loggerHandlerKey("logging.Handler") // LoggerHandler returns the extra handler the job logger sends its records to, or nil. // It affects the job logger, not the global logger. func LoggerHandler(ctx context.Context) logging.Handler { - if h, ok := ctx.Value(loggerHandlerKeyVal).(logging.Handler); ok { - return h - } - return nil + h, _ := ctx.Value(loggerHandlerKeyVal).(logging.Handler) + return h } // WithLoggerHandler adds the extra handler for the job logger to the context diff --git a/act/common/logging/console.go b/act/common/logging/console.go index 1b0bb22b..aae8f0c3 100644 --- a/act/common/logging/console.go +++ b/act/common/logging/console.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log/slog" + "maps" "reflect" "runtime" "slices" @@ -94,11 +95,7 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error { } prefixFieldClashes(data, caller != nil) - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - slices.Sort(keys) + keys := slices.Sorted(maps.Keys(data)) var b bytes.Buffer if terminal && !opts.DisableColors { diff --git a/act/common/logging/global.go b/act/common/logging/global.go index a4cce31b..cae2d069 100644 --- a/act/common/logging/global.go +++ b/act/common/logging/global.go @@ -73,12 +73,10 @@ func Enabled(level Level) bool { return Default().Enabled(level) } func With(args ...any) *Logger { return Default().With(args...) } func WithError(err error) *Logger { return Default().WithError(err) } -func Tracef(format string, args ...any) { Default().logf(LevelTrace, format, args) } func Debugf(format string, args ...any) { Default().logf(LevelDebug, format, args) } func Infof(format string, args ...any) { Default().logf(LevelInfo, format, args) } func Warnf(format string, args ...any) { Default().logf(LevelWarn, format, args) } func Errorf(format string, args ...any) { Default().logf(LevelError, format, args) } -func Trace(args ...any) { Default().log(LevelTrace, args) } func Debug(args ...any) { Default().log(LevelDebug, args) } func Info(args ...any) { Default().log(LevelInfo, args) } func Warn(args ...any) { Default().log(LevelWarn, args) } diff --git a/act/common/logging/logger.go b/act/common/logging/logger.go index 741da8a4..ad4fbc6f 100644 --- a/act/common/logging/logger.go +++ b/act/common/logging/logger.go @@ -43,7 +43,6 @@ func (l *Logger) Enabled(level Level) bool { return l.h.Enabled(l.ctx, level) } -func (l *Logger) Tracef(format string, args ...any) { l.logf(LevelTrace, format, args) } func (l *Logger) Debugf(format string, args ...any) { l.logf(LevelDebug, format, args) } func (l *Logger) Infof(format string, args ...any) { l.logf(LevelInfo, format, args) } func (l *Logger) Warnf(format string, args ...any) { l.logf(LevelWarn, format, args) } diff --git a/act/common/logging/logging_test.go b/act/common/logging/logging_test.go index f577a749..624653c1 100644 --- a/act/common/logging/logging_test.go +++ b/act/common/logging/logging_test.go @@ -38,6 +38,7 @@ func (c countingStringer) String() string { *c.n++; return "x" } func TestLogger(t *testing.T) { h := logtest.NewWithLevel(logging.LevelTrace) + assert.Nil(t, h.LastRecord()) l := logging.New(h) l.With("job", "j1").WithError(errors.New("boom")).Warnf("step %d", 2) @@ -53,12 +54,9 @@ func TestLogger(t *testing.T) { assert.Equal(t, "j1", job) errv, _ := recs[0].Attr("error") require.EqualError(t, errv.(error), "boom") - assert.Equal(t, "a1 2b", recs[1].Message) // fmt.Sprint spacing + assert.Equal(t, "a1 2b", recs[1].Message) bad, _ := recs[2].Attr("!BADKEY") assert.Equal(t, 7, bad) - - h.Reset() - assert.Nil(t, h.LastRecord()) } func TestLoggerSkipsFormattingWhenDisabled(t *testing.T) { @@ -117,10 +115,8 @@ func TestDefaultLevel(t *testing.T) { func TestSlogHandler(t *testing.T) { var buf strings.Builder - slogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - l := logging.New(logging.NewSlogHandler(slogger.Handler())).With("job", "j1") + l := logging.New(logging.NewSlogHandler(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))).With("job", "j1") - l.Tracef("dropped") assert.False(t, l.Enabled(logging.LevelTrace)) l.WithError(errors.New("boom")).Errorf("fail %s", "x") diff --git a/act/common/logging/logtest/logtest.go b/act/common/logging/logtest/logtest.go index ad5cd3d3..bf090212 100644 --- a/act/common/logging/logtest/logtest.go +++ b/act/common/logging/logtest/logtest.go @@ -72,12 +72,6 @@ func (h *Handler) LastRecord() *logging.Record { return &r } -func (h *Handler) Reset() { - h.store.mu.Lock() - defer h.store.mu.Unlock() - h.store.records = nil -} - // SetDefault makes the process-wide Logger record into a new Handler until the test ends. func SetDefault(t testing.TB) *Handler { t.Helper() diff --git a/act/common/outbound_ip.go b/act/common/outbound_ip.go index 55a29c44..c813b7ef 100644 --- a/act/common/outbound_ip.go +++ b/act/common/outbound_ip.go @@ -12,7 +12,7 @@ import ( type interfaceIP struct { net.IP - net.Interface + Name string } // GetOutboundIP returns an outbound IP address of this machine. @@ -43,7 +43,7 @@ func GetOutboundIP() net.IP { ip = v.IP } if ip.IsGlobalUnicast() { - ips = append(ips, interfaceIP{ip, i}) + ips = append(ips, interfaceIP{ip, i.Name}) } } } @@ -59,29 +59,25 @@ func preferredIP(ips []interfaceIP) net.IP { return nil } sort.Slice(ips, func(i, j int) bool { - ifi := ips[i].Interface - ifj := ips[j].Interface + first, second := ips[i], ips[j] // ethernet is preferred - if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj { + if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj { return vi } - ipi := ips[i].IP - ipj := ips[j].IP - // IPv4 is preferred - if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj { + if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj { return vi } // en0 is preferred to en1 - if ifi.Name != ifj.Name { - return ifi.Name < ifj.Name + if first.Name != second.Name { + return first.Name < second.Name } // fallback - return ipi.String() < ipj.String() + return first.String() < second.String() }) return ips[0].IP } diff --git a/act/container/docker_build.go b/act/container/docker_build.go index 2beb62ca..06cd3697 100644 --- a/act/container/docker_build.go +++ b/act/container/docker_build.go @@ -46,9 +46,8 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { logger.Debugf("Building image from '%v'", input.ContextDir) - tags := []string{input.ImageTag} options := client.ImageBuildOptions{ - Tags: tags, + Tags: []string{input.ImageTag}, Remove: true, AuthConfigs: LoadDockerAuthConfigs(ctx), Dockerfile: input.Dockerfile, @@ -76,11 +75,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform) resp, err := cli.ImageBuild(ctx, buildContext, options) - err = logDockerResponse(logger, resp.Body, err != nil) - if err != nil { - return err - } - return nil + return logDockerResponse(logger, resp.Body, err != nil) } } diff --git a/act/container/docker_cli.go b/act/container/docker_cli.go index f0747709..10c73b21 100644 --- a/act/container/docker_cli.go +++ b/act/container/docker_cli.go @@ -4,12 +4,12 @@ //go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) -// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with: +// This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with: // * appended with license information // * regexp and ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec // * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName // * jsontext in place of json.Compact for the seccomp profile -// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go +// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for nat port types // // docker/cli is licensed under the Apache License, Version 2.0. // See DOCKER_LICENSE for the full license text. @@ -154,9 +154,6 @@ type containerOptions struct { init bool umask opts.UmaskOpt annotations *opts.MapOpts - - Image string - Args []string } // addFlags adds all command line flags that will be used by parse to the FlagSet @@ -422,12 +419,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con tmpfs[k] = v } - var runCmd, entrypoint []string - - if len(copts.Args) > 0 { - runCmd = copts.Args - } - + var entrypoint []string if copts.entrypoint != "" { entrypoint = []string{copts.entrypoint} } else if flags.Changed("entrypoint") { @@ -448,36 +440,15 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con if err != nil { return nil, err } - portBindings := network.PortMap{} - for port, bindings := range natPortBindings { - p, err := network.ParsePort(string(port)) - if err != nil { - return nil, err - } - portBindings[p] = []network.PortBinding{} - for _, b := range bindings { - var hostIP netip.Addr - if b.HostIP != "" { - hostIP, err = netip.ParseAddr(b.HostIP) - if err != nil { - return nil, err - } - } - portBindings[p] = append(portBindings[p], network.PortBinding{ - HostIP: hostIP, - HostPort: b.HostPort, - }) - } + portBindings, err := convertPortMap(natPortBindings) + if err != nil { + return nil, err } // Add published ports as exposed ports. - exposedPorts := network.PortSet{} - for port := range ports { - p, err := network.ParsePort(string(port)) - if err != nil { - return nil, err - } - exposedPorts[p] = struct{}{} + exposedPorts, err := convertPortSet(ports) + if err != nil { + return nil, err } // Merge in exposed ports to the map of published ports @@ -618,11 +589,10 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con deviceRequests := copts.gpus.Value() if len(cdiDeviceNames) > 0 { - cdiDeviceRequest := container.DeviceRequest{ + deviceRequests = append(deviceRequests, container.DeviceRequest{ Driver: "cdi", DeviceIDs: cdiDeviceNames, - } - deviceRequests = append(deviceRequests, cdiDeviceRequest) + }) } resources := container.Resources{ @@ -668,8 +638,6 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con AttachStdout: attachStdout, AttachStderr: attachStderr, Env: envVariables, - Cmd: runCmd, - Image: copts.Image, Volumes: volumes, Entrypoint: entrypoint, WorkingDir: copts.workingDir, @@ -810,10 +778,8 @@ func parseNetworkOpts(copts *containerOptions) (map[string]*network.EndpointSett // For backward compatibility: if no custom options are provided for the network, // and only a single network is specified, omit the endpoint-configuration // on the client (the daemon will still create it when creating the container) - if i == 0 && len(copts.netMode.Value()) == 1 { - if ep == nil || reflect.ValueOf(*ep).IsZero() { - continue - } + if i == 0 && len(copts.netMode.Value()) == 1 && reflect.ValueOf(*ep).IsZero() { + continue } endpoints[n.Target] = ep } @@ -844,14 +810,12 @@ func applyContainerOptions(n *opts.NetworkAttachmentOpts, copts *containerOption return invalidParameter(errors.New("conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses")) } if copts.aliases.Len() > 0 { - n.Aliases = make([]string, copts.aliases.Len()) - copy(n.Aliases, copts.aliases.GetSlice()) + n.Aliases = slices.Clone(copts.aliases.GetSlice()) } // For a user-defined network, "--link" is an endpoint option, it creates an alias. But, // for the default bridge it defines a legacy-link. if container.NetworkMode(n.Target).IsUserDefined() && copts.links.Len() > 0 { - n.Links = make([]string, copts.links.Len()) - copy(n.Links, copts.links.GetSlice()) + n.Links = slices.Clone(copts.links.GetSlice()) } if copts.ipv4Address != nil { if ipv4, ok := netip.AddrFromSlice(copts.ipv4Address.To4()); ok { @@ -890,7 +854,6 @@ func parseNetworkAttachmentOpt(ep opts.NetworkAttachmentOpts) (*network.Endpoint } epConfig.Aliases = append(epConfig.Aliases, ep.Aliases...) if len(ep.DriverOpts) > 0 { - epConfig.DriverOpts = make(map[string]string) epConfig.DriverOpts = ep.DriverOpts } if len(ep.Links) > 0 { @@ -934,11 +897,10 @@ func convertToStandardNotation(ports []string) ([]string, error) { } func parseLoggingOpts(loggingDriver string, loggingOpts []string) (map[string]string, error) { - loggingOptsMap := opts.ConvertKVStringsToMap(loggingOpts) if loggingDriver == "none" && len(loggingOpts) > 0 { return map[string]string{}, fmt.Errorf("invalid logging opts for driver %s", loggingDriver) } - return loggingOptsMap, nil + return opts.ConvertKVStringsToMap(loggingOpts), nil } // takes a local seccomp daemon, reads the file contents for sending to the daemon diff --git a/act/container/docker_cli_test.go b/act/container/docker_cli_test.go index 7a4ef542..1bf73eef 100644 --- a/act/container/docker_cli_test.go +++ b/act/container/docker_cli_test.go @@ -2,7 +2,7 @@ // Copyright 2022 The nektos/act Authors. All rights reserved. // SPDX-License-Identifier: MIT -// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with: +// This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with: // * appended with license information // * added tests for the locally changed parseDevice, validateDevice and invalidParameter // @@ -69,7 +69,6 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network if err := flags.Parse(args); err != nil { return nil, nil, nil, err } - // TODO(dnephin): fix tests to accept ContainerConfig; see https://github.com/moby/moby/pull/31621 containerCfg, err := parse(flags, copts, runtime.GOOS) if err != nil { return nil, nil, nil, err @@ -81,13 +80,12 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) { flags := pflag.NewFlagSet("run", pflag.ContinueOnError) flags.SetOutput(io.Discard) flags.Usage = nil - copts := addFlags(flags) - return flags, copts + return flags, addFlags(flags) } func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) { t.Helper() - config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash")) + config, hostConfig, nwConfig, err := parseRun(strings.Split(args, " ")) require.NoError(t, err) return config, hostConfig, nwConfig } @@ -179,53 +177,21 @@ func TestParseRunUmask(t *testing.T) { } func TestParseRunAttach(t *testing.T) { - tests := []struct { - input string - expected container.Config + for _, tc := range []struct { + input string + stdin, stdout, stderr bool }{ - { - input: "", - expected: container.Config{ - AttachStdout: true, - AttachStderr: true, - }, - }, - { - input: "-i", - expected: container.Config{ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - }, - }, - { - input: "-a stdin", - expected: container.Config{ - AttachStdin: true, - }, - }, - { - input: "-a stdin -a stdout", - expected: container.Config{ - AttachStdin: true, - AttachStdout: true, - }, - }, - { - input: "-a stdin -a stdout -a stderr", - expected: container.Config{ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - }, - }, - } - for _, tc := range tests { + {"", false, true, true}, + {"-i", true, true, true}, + {"-a stdin", true, false, false}, + {"-a stdin -a stdout", true, true, false}, + {"-a stdin -a stdout -a stderr", true, true, true}, + } { t.Run(tc.input, func(t *testing.T) { config, _, _ := mustParse(t, tc.input) - require.Equal(t, tc.expected.AttachStdin, config.AttachStdin) - require.Equal(t, tc.expected.AttachStdout, config.AttachStdout) - require.Equal(t, tc.expected.AttachStderr, config.AttachStderr) + require.Equal(t, tc.stdin, config.AttachStdin) + require.Equal(t, tc.stdout, config.AttachStdout) + require.Equal(t, tc.stderr, config.AttachStderr) }) } } @@ -276,104 +242,47 @@ func TestParseRunWithInvalidArgs(t *testing.T) { } } -func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests - // A single volume - arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`}) - if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { - t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) - } else if _, exists := config.Volumes[arr[0]]; !exists { - t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes) - } - - // Two volumes - arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}) - if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { - t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) - } else if _, exists := config.Volumes[arr[0]]; !exists { - t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes) - } else if _, exists := config.Volumes[arr[1]]; !exists { - t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[1], config.Volumes) - } - - // A single bind mount - arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`}) - if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] { - t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes) - } - - // Two bind mounts. - arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`}) - if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { - t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) - } - - // Two bind mounts, first read-only, second read-write. - // TODO Windows: The Windows version uses read-write as that's the only mode it supports. Can change this post TP4 - arr, tryit = setupPlatformVolume( - []string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`}, - []string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`}) - if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { - t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) - } - - // Similar to previous test but with alternate modes which are only supported by Linux - if runtime.GOOS != "windows" { - arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{}) - if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { - t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) +func TestParseWithVolumes(t *testing.T) { + for _, tc := range []struct { + unix, windows []string + binds int + }{ + {[]string{`/tmp`}, []string{`c:\tmp`}, 0}, + {[]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}, 0}, + {[]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`}, 1}, + {[]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`}, 2}, + {[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`}, []string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`}, 2}, + {[]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, nil, 2}, + {[]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, nil, 2}, + {[]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`}, 1}, + {nil, []string{os.Getenv("SystemDrive") + `\:d:`}, 1}, + } { + volumes := tc.unix + if runtime.GOOS == "windows" { + volumes = tc.windows } - - arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{}) - if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { - t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) + if len(volumes) == 0 { + continue } + t.Run(strings.Join(volumes, ","), func(t *testing.T) { + var args []string + for _, volume := range volumes { + args = append(args, "-v", volume) + } + config, hostConfig, _, err := parseRun(args) + require.NoError(t, err) + if tc.binds == 0 { + assert.Nil(t, hostConfig.Binds) + } else { + assert.ElementsMatch(t, volumes[:tc.binds], hostConfig.Binds) + } + expectedVolumes := map[string]struct{}{} + for _, volume := range volumes[tc.binds:] { + expectedVolumes[volume] = struct{}{} + } + assert.Equal(t, expectedVolumes, config.Volumes) + }) } - - // One bind mount and one volume - arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`}) - if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] { - t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds) - } else if _, exists := config.Volumes[arr[1]]; !exists { - t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes) - } - - // Root to non-c: drive letter (Windows specific) - if runtime.GOOS == "windows" { - arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`}) - if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 { - t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0]) - } - } -} - -// setupPlatformVolume takes two arrays of volume specs - a Unix style -// spec and a Windows style spec. Depending on the platform being unit tested, -// it returns one of them, along with a volume string that would be passed -// on the docker CLI (e.g. -v /bar -v /foo). -func setupPlatformVolume(u, w []string) ([]string, string) { - var a []string - if runtime.GOOS == "windows" { - a = w - } else { - a = u - } - s := "" - for _, v := range a { - s = s + "-v " + v + " " - } - return a, s -} - -// check if (a == c && b == d) || (a == d && b == c) -// because maps are randomized -func compareRandomizedStrings(a, b, c, d string) error { - if a == c && b == d { - return nil - } - if a == d && b == c { - return nil - } - return errors.New("strings don't match") } // Simple parse with MacAddress validation @@ -414,46 +323,29 @@ func TestParseWithMemorySwap(t *testing.T) { } func TestParseHostname(t *testing.T) { - validHostnames := map[string]string{ - "hostname": "hostname", - "host-name": "host-name", - "hostname123": "hostname123", - "123hostname": "123hostname", - "hostname-of-63-bytes-long-should-be-valid-and-without-any-error": "hostname-of-63-bytes-long-should-be-valid-and-without-any-error", - } - hostnameWithDomain := "--hostname=hostname.domainname" - hostnameWithDomainTld := "--hostname=hostname.domainname.tld" - for hostname, expectedHostname := range validHostnames { - if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname { - t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname) - } - } - if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" { - t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname) - } - if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" { - t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname) - } -} - -func TestParseHostnameDomainname(t *testing.T) { - validDomainnames := map[string]string{ - "domainname": "domainname", - "domain-name": "domain-name", - "domainname123": "domainname123", - "123domainname": "123domainname", - "domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors", - } - for domainname, expectedDomainname := range validDomainnames { - if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname { - t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname) - } - } - if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" { - t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname) - } - if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" { - t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname) + for _, tc := range []struct { + input, hostname, domainname string + }{ + {"--hostname=hostname", "hostname", ""}, + {"--hostname=host-name", "host-name", ""}, + {"--hostname=hostname123", "hostname123", ""}, + {"--hostname=123hostname", "123hostname", ""}, + {"--hostname=hostname-of-63-bytes-long-should-be-valid-and-without-any-error", "hostname-of-63-bytes-long-should-be-valid-and-without-any-error", ""}, + {"--hostname=hostname.domainname", "hostname.domainname", ""}, + {"--hostname=hostname.domainname.tld", "hostname.domainname.tld", ""}, + {"--domainname=domainname", "", "domainname"}, + {"--domainname=domain-name", "", "domain-name"}, + {"--domainname=domainname123", "", "domainname123"}, + {"--domainname=123domainname", "", "123domainname"}, + {"--domainname=domainname-63-bytes-long-should-be-valid-and-without-any-errors", "", "domainname-63-bytes-long-should-be-valid-and-without-any-errors"}, + {"--hostname=some.prefix --domainname=domainname", "some.prefix", "domainname"}, + {"--hostname=another-prefix --domainname=domainname.tld", "another-prefix", "domainname.tld"}, + } { + t.Run(tc.input, func(t *testing.T) { + config, _, _ := mustParse(t, tc.input) + require.Equal(t, tc.hostname, config.Hostname) + require.Equal(t, tc.domainname, config.Domainname) + }) } } @@ -497,9 +389,15 @@ func TestParseWithExpose(t *testing.T) { }) t.Run("merge with published", func(t *testing.T) { - // Merge with actual published port - config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"}) + config, hostConfig, _, err := parseRun([]string{"--publish=80", "--publish=127.0.0.1:8080:80", "--publish=[::1]:8081:80", "--expose=80-81/tcp"}) require.NoError(t, err) + assert.Equal(t, networktypes.PortMap{ + networktypes.MustParsePort("80/tcp"): { + {}, + {HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "8080"}, + {HostIP: netip.MustParseAddr("::1"), HostPort: "8081"}, + }, + }, hostConfig.PortBindings) assert.Len(t, config.ExposedPorts, 2) ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")} for _, port := range ports { @@ -984,41 +882,29 @@ func TestParseRestartPolicyAutoRemove(t *testing.T) { } func TestParseHealth(t *testing.T) { - checkOk := func(args ...string) *container.HealthConfig { - config, _, _, err := parseRun(args) - if err != nil { - t.Fatalf("%#v: %v", args, err) - } - return config.Healthcheck - } - checkError := func(expected string, args ...string) { - config, _, _, err := parseRun(args) - if err == nil { - t.Fatalf("Expected error, but got %#v", config) - } - if err.Error() != expected { - t.Fatalf("Expected %#v, got %#v", expected, err) - } - } - health := checkOk("--no-healthcheck", "img", "cmd") - if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" { - t.Fatalf("--no-healthcheck failed: %#v", health) - } - - health = checkOk("--health-cmd=/check.sh -q", "img", "cmd") - if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh -q" { - t.Fatalf("--health-cmd: got %#v", health.Test) - } - if health.Timeout != 0 { - t.Fatalf("--health-cmd: timeout = %s", health.Timeout) - } - - checkError("--no-healthcheck conflicts with --health-* options", - "--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd") - - health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd") - if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second { - t.Fatalf("--health-*: got %#v", health) + for _, tc := range []struct { + args []string + expected *container.HealthConfig + wantErr string + }{ + {[]string{"--no-healthcheck", "img", "cmd"}, &container.HealthConfig{Test: []string{"NONE"}}, ""}, + {[]string{"--health-cmd=/check.sh -q", "img", "cmd"}, &container.HealthConfig{Test: []string{"CMD-SHELL", "/check.sh -q"}}, ""}, + {[]string{"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd"}, nil, "--no-healthcheck conflicts with --health-* options"}, + { + []string{"--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd"}, + &container.HealthConfig{Timeout: 2 * time.Second, Retries: 3, Interval: 4500 * time.Millisecond, StartPeriod: 5 * time.Second, StartInterval: time.Second}, + "", + }, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + config, _, _, err := parseRun(tc.args) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, config.Healthcheck) + } + }) } } @@ -1037,82 +923,53 @@ func TestParseLoggingOpts(t *testing.T) { } } -func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests - expErr := "--env-file: open nonexistent: no such file or directory" - if runtime.GOOS == "windows" { - expErr = "--env-file: open nonexistent: The system cannot find the file specified." - } - // env ko - if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr { - t.Fatalf("Expected an error with message '%s', got %v", expErr, err) - } - // env ok - config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"}) - if err != nil { - t.Fatal(err) - } - if len(config.Env) != 1 || config.Env[0] != "ENV1=value1" { - t.Fatalf("Expected a config with [ENV1=value1], got %v", config.Env) - } - config, _, _, err = parseRun([]string{"--env-file=testdata/valid.env", "--env=ENV2=value2", "img", "cmd"}) - if err != nil { - t.Fatal(err) - } - if len(config.Env) != 2 || config.Env[0] != "ENV1=value1" || config.Env[1] != "ENV2=value2" { - t.Fatalf("Expected a config with [ENV1=value1 ENV2=value2], got %v", config.Env) +func TestParseFileVariables(t *testing.T) { + for _, tc := range []struct { + flag, file, key string + }{ + {"env", "valid.env", "ENV"}, + {"label", "valid.label", "LABEL"}, + } { + t.Run(tc.flag, func(t *testing.T) { + expectedError := "no such file or directory" + if runtime.GOOS == "windows" { + expectedError = "The system cannot find the file specified." + } + _, _, _, err := parseRun([]string{"--" + tc.flag + "-file=nonexistent"}) + require.EqualError(t, err, "--"+tc.flag+"-file: open nonexistent: "+expectedError) + for _, extra := range []bool{false, true} { + args := []string{"--" + tc.flag + "-file=testdata/" + tc.file} + expected := []string{tc.key + "1=value1"} + if extra { + args = append(args, "--"+tc.flag+"="+tc.key+"2=value2") + expected = append(expected, tc.key+"2=value2") + } + config, _, _, err := parseRun(args) + require.NoError(t, err) + if tc.flag == "env" { + assert.Equal(t, expected, config.Env) + } else { + assert.Len(t, config.Labels, len(expected)) + for _, label := range expected { + key, value, _ := strings.Cut(label, "=") + assert.Equal(t, value, config.Labels[key]) + } + } + } + }) } } func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) { - // UTF8 with BOM - config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env", "img", "cmd"}) - if err != nil { - t.Fatal(err) - } - env := []string{"FOO=BAR", "HELLO=" + string([]byte{0xe6, 0x82, 0xa8, 0xe5, 0xa5, 0xbd}), "BAR=FOO"} - if len(config.Env) != len(env) { - t.Fatalf("Expected a config with %d env variables, got %v: %v", len(env), len(config.Env), config.Env) - } - for i, v := range env { - if config.Env[i] != v { - t.Fatalf("Expected a config with [%s], got %v", v, []byte(config.Env[i])) - } - } + config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env"}) + require.NoError(t, err) + assert.Equal(t, []string{"FOO=BAR", "HELLO=您好", "BAR=FOO"}, config.Env) - // UTF16 with BOM - e := "invalid utf8 bytes at line" - if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { - t.Fatalf("Expected an error with message '%s', got %v", e, err) - } - // UTF16BE with BOM - if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16be.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { - t.Fatalf("Expected an error with message '%s', got %v", e, err) - } -} - -func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests - expErr := "--label-file: open nonexistent: no such file or directory" - if runtime.GOOS == "windows" { - expErr = "--label-file: open nonexistent: The system cannot find the file specified." - } - // label ko - if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr { - t.Fatalf("Expected an error with message '%s', got %v", expErr, err) - } - // label ok - config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"}) - if err != nil { - t.Fatal(err) - } - if len(config.Labels) != 1 || config.Labels["LABEL1"] != "value1" { - t.Fatalf("Expected a config with [LABEL1:value1], got %v", config.Labels) - } - config, _, _, err = parseRun([]string{"--label-file=testdata/valid.label", "--label=LABEL2=value2", "img", "cmd"}) - if err != nil { - t.Fatal(err) - } - if len(config.Labels) != 2 || config.Labels["LABEL1"] != "value1" || config.Labels["LABEL2"] != "value2" { - t.Fatalf("Expected a config with [LABEL1:value1 LABEL2:value2], got %v", config.Labels) + for _, file := range []string{"utf16.env", "utf16be.env"} { + t.Run(file, func(t *testing.T) { + _, _, _, err := parseRun([]string{"--env-file=testdata/" + file}) + require.ErrorContains(t, err, "invalid utf8 bytes at line") + }) } } diff --git a/act/container/docker_logger.go b/act/container/docker_logger.go index e0fda370..5baeb08c 100644 --- a/act/container/docker_logger.go +++ b/act/container/docker_logger.go @@ -33,18 +33,9 @@ func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isE defer dockerResponse.Close() scanner := bufio.NewScanner(dockerResponse) - msg := dockerMessage{} - for scanner.Scan() { line := scanner.Bytes() - - msg.ID = "" - msg.Stream = "" - msg.Error = "" - msg.ErrorDetail.Message = "" - msg.Status = "" - msg.Progress = "" - + var msg dockerMessage if err := json.Unmarshal(line, &msg); err != nil { writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err) continue diff --git a/act/container/docker_platform_test.go b/act/container/docker_platform_test.go index e75eb310..470cb3c7 100644 --- a/act/container/docker_platform_test.go +++ b/act/container/docker_platform_test.go @@ -6,6 +6,7 @@ package container import ( "testing" + specs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,32 +18,17 @@ func TestParsePlatform(t *testing.T) { assert.Nil(t, got) }) - t.Run("os/arch", func(t *testing.T) { - got, err := parsePlatform("linux/amd64") - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "linux", got.OS) - assert.Equal(t, "amd64", got.Architecture) - assert.Empty(t, got.Variant) - }) - - t.Run("os/arch/variant", func(t *testing.T) { - got, err := parsePlatform("linux/arm/v7") - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "linux", got.OS) - assert.Equal(t, "arm", got.Architecture) - assert.Equal(t, "v7", got.Variant) - }) - - t.Run("input is lowercased", func(t *testing.T) { - got, err := parsePlatform("Linux/AMD64/V8") - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "linux", got.OS) - assert.Equal(t, "amd64", got.Architecture) - assert.Equal(t, "v8", got.Variant) - }) + for input, want := range map[string]specs.Platform{ + "linux/amd64": {OS: "linux", Architecture: "amd64"}, + "linux/arm/v7": {OS: "linux", Architecture: "arm", Variant: "v7"}, + "Linux/AMD64/V8": {OS: "linux", Architecture: "amd64", Variant: "v8"}, + } { + t.Run(input, func(t *testing.T) { + got, err := parsePlatform(input) + require.NoError(t, err) + assert.Equal(t, &want, got) + }) + } for _, bad := range []string{ "amd64", diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 508b5fb5..a4c3f248 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -50,15 +50,12 @@ const drainGracePeriod = 2 * time.Second // NewContainer creates a reference to a container func NewContainer(input *NewContainerInput) ExecutionsEnvironment { - cr := new(containerReference) - cr.input = input // Resolved up front because the image pull runs before the container is created. cf := createFlagsFromOptions(input.allOptions()) if cf.platform != "" { - cr.input.Platform = cf.platform + input.Platform = cf.platform } - cr.pullPolicy = cf.pull - return cr + return &containerReference{input: input, pullPolicy: cf.pull} } // supportsContainerImagePlatform reports whether the Docker server API version @@ -394,17 +391,16 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) { return cli, nil } -func GetHostInfo(ctx context.Context) (info system.Info, err error) { - var cli client.APIClient - cli, err = GetDockerClient(ctx) +func GetHostInfo(ctx context.Context) (system.Info, error) { + cli, err := GetDockerClient(ctx) if err != nil { - return info, err + return system.Info{}, err } defer cli.Close() result, err := cli.Info(ctx, client.InfoOptions{}) if err != nil { - return info, err + return system.Info{}, err } return result.Info, nil @@ -559,32 +555,32 @@ func (input *NewContainerInput) allOptions() string { return strings.TrimSpace(input.RunnerOptions + " " + input.WorkflowOptions) } -func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) { +func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) error { logger := common.Logger(ctx) options := cr.input.allOptions() if options == "" { - return config, hostConfig, nil + return nil } // For Gitea, checked here because the parse below is what would read those files if err := rejectHostReadingOptions(cr.input.WorkflowOptions); err != nil { - return nil, nil, err + return err } // parse configuration from CLI container.options flags, copts, cf, err := parseContainerOptions(options) if err != nil { - return nil, nil, err + return err } if err := cf.validate(); err != nil { - return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) + return fmt.Errorf("cannot process container options: '%s': '%w'", options, err) } if len(copts.netMode.Value()) == 0 { if err = copts.netMode.Set(cr.input.NetworkMode); err != nil { - return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err) + return fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err) } } @@ -597,7 +593,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config containerConfig, err := parse(flags, copts, runtime.GOOS) if err != nil { - return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) + return fmt.Errorf("cannot process container options: '%s': '%w'", options, err) } // workflow aliases join the runner's own, deduped so a re-create cannot grow the input for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig { @@ -612,7 +608,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config if !hostConfig.Privileged { trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions) if err != nil { - return nil, nil, err + return err } sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted) } @@ -624,7 +620,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig) - cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own + cr.sanitizeConfig(ctx, containerConfig.HostConfig) // a dropped volume must not displace the runner's own overlayVolumes(hostConfig, containerConfig.HostConfig) binds := hostConfig.Binds mounts := hostConfig.Mounts @@ -645,7 +641,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config hostConfig.NetworkMode = networkMode logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig) - return config, hostConfig, nil + return nil } func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { @@ -711,8 +707,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { AutoRemove: input.AutoRemove, } - config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig) - if err != nil { + if err := cr.mergeContainerConfigs(ctx, config, hostConfig); err != nil { return err } @@ -721,18 +716,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { } // For Gitea - config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig) + cr.sanitizeConfig(ctx, hostConfig) var networkingConfig *network.NetworkingConfig n := hostConfig.NetworkMode // IsUserDefined and IsHost are broken on windows if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 { - endpointConfig := &network.EndpointSettings{ - Aliases: input.NetworkAliases, - } networkingConfig = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ - input.NetworkMode: endpointConfig, + input.NetworkMode: {Aliases: input.NetworkAliases}, }, } } @@ -773,15 +765,11 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo logger.Debugf("Exec command '%s'", cmd) isTerminal := cr.input.AllocatePTY - var wd string - if workdir != "" { - if strings.HasPrefix(workdir, "/") { - wd = workdir - } else { - wd = fmt.Sprintf("%s/%s", cr.input.WorkingDir, workdir) - } - } else { - wd = cr.input.WorkingDir + wd := cr.input.WorkingDir + if strings.HasPrefix(workdir, "/") { + wd = workdir + } else if workdir != "" { + wd = fmt.Sprintf("%s/%s", cr.input.WorkingDir, workdir) } logger.Debugf("Working directory '%s'", wd) @@ -806,8 +794,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo } defer resp.Close() - err = cr.waitForCommand(ctx, resp.HijackedResponse) - if err != nil { + if err := cr.waitForCommand(ctx, resp.HijackedResponse); err != nil { return err } @@ -909,17 +896,14 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski return err } logger.Debugf("Writing tarball %s from %s", tarFile.Name(), srcPath) - defer func(tarFile *os.File) { - name := tarFile.Name() - err := tarFile.Close() - if !errors.Is(err, os.ErrClosed) { + defer func() { + if err := tarFile.Close(); !errors.Is(err, os.ErrClosed) { logger.Error(err) } - err = os.Remove(name) - if err != nil { + if err := os.Remove(tarFile.Name()); err != nil { logger.Error(err) } - }(tarFile) + }() extractionRoot := "/" if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") { extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink @@ -929,8 +913,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski } logger.Debugf("Extracting content from '%s' to '%s'", tarFile.Name(), dstPath) - _, err = tarFile.Seek(0, 0) - if err != nil { + if _, err := tarFile.Seek(0, 0); err != nil { return fmt.Errorf("failed to seek tar archive: %w", err) } return cr.upload(ctx, extractionRoot, tarFile) @@ -1098,9 +1081,7 @@ func parseOptionsHostConfig(options string) (*container.HostConfig, error) { return containerConfig.HostConfig, nil } -// For Gitea -// sanitizeConfig remove the invalid configurations from `config` and `hostConfig` -func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) { +func (cr *containerReference) sanitizeConfig(ctx context.Context, hostConfig *container.HostConfig) { logger := common.Logger(ctx) matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes) @@ -1149,13 +1130,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai } } hostConfig.Mounts = sanitizedMounts - - return config, hostConfig } func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string { cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}} - _, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds}) + hostConfig := &container.HostConfig{Binds: binds} + cr.sanitizeConfig(ctx, hostConfig) return hostConfig.Binds } diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index 7ac89c44..8f499751 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -186,11 +186,9 @@ func TestDockerExecAbort(t *testing.T) { }, nil) cr := &containerReference{ - id: "123", - cli: client, - input: &NewContainerInput{ - Image: "image", - }, + id: "123", + cli: client, + input: &NewContainerInput{}, } channel := make(chan error) @@ -213,12 +211,10 @@ func TestDockerExecAbort(t *testing.T) { func TestDockerExecFailure(t *testing.T) { ctx := t.Context() - conn := &mockConn{} - client := &mockDockerClient{} client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil) client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{ - Conn: conn, + Conn: &mockConn{}, Reader: bufio.NewReader(strings.NewReader("output")), }, nil) client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{ @@ -226,11 +222,9 @@ func TestDockerExecFailure(t *testing.T) { }, nil) cr := &containerReference{ - id: "123", - cli: client, - input: &NewContainerInput{ - Image: "image", - }, + id: "123", + cli: client, + input: &NewContainerInput{}, } err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx) @@ -239,7 +233,6 @@ func TestDockerExecFailure(t *testing.T) { assert.Equal(t, ExitCodeError(1), exitErr) assert.Equal(t, "Process completed with exit code 1.", err.Error()) - conn.AssertExpectations(t) client.AssertExpectations(t) } @@ -254,9 +247,6 @@ func stdcopyFrame(stream stdcopy.StdType, payload string) []byte { return b } -// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the -// attach() streaming goroutine has drained and flushed the container's output, -// so a final line without a trailing newline is not lost. func TestDockerAttachFlushesTrailingLine(t *testing.T) { ctx := t.Context() @@ -277,18 +267,13 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) { statusCh := make(chan container.WaitResponse, 1) statusCh <- container.WaitResponse{StatusCode: 0} - errCh := make(chan error, 1) client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}). - Return(mobyclient.ContainerWaitResult{ - Result: (<-chan container.WaitResponse)(statusCh), - Error: (<-chan error)(errCh), - }) + Return(mobyclient.ContainerWaitResult{Result: statusCh}) cr := &containerReference{ id: "123", cli: client, input: &NewContainerInput{ - Image: "image", Stdout: logWriter, Stderr: logWriter, }, @@ -297,10 +282,6 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) { require.NoError(t, cr.attach()(ctx)) require.NoError(t, cr.wait()(ctx)) - // wait() must have blocked until the goroutine drained AND flushed; the - // trailing, non-newline-terminated line must therefore be present. Reading - // lines here is race-free because wait() synchronizes on attachDone, which - // the goroutine closes after the final append. assert.Equal(t, []string{"line one\n", "last line without newline"}, lines) client.AssertExpectations(t) @@ -311,24 +292,11 @@ func TestDockerWaitFailure(t *testing.T) { statusCh := make(chan container.WaitResponse, 1) statusCh <- container.WaitResponse{StatusCode: 2} - errCh := make(chan error, 1) - client := &mockDockerClient{} client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}). - Return(mobyclient.ContainerWaitResult{ - Result: (<-chan container.WaitResponse)(statusCh), - Error: (<-chan error)(errCh), - }) + Return(mobyclient.ContainerWaitResult{Result: statusCh}) - cr := &containerReference{ - id: "123", - cli: client, - input: &NewContainerInput{ - Image: "image", - }, - } - - err := cr.wait()(ctx) + err := (&containerReference{id: "123", cli: client}).wait()(ctx) var exitErr ExitCodeError require.ErrorAs(t, err, &exitErr) assert.Equal(t, ExitCodeError(2), exitErr) @@ -402,79 +370,49 @@ func TestRemoveWithoutIDUsesName(t *testing.T) { client.AssertExpectations(t) } -// find() must drop a stale cached id so later Copy/Exec don't hit the -// daemon with a torn-down container. func TestFindRevalidatesStaleID(t *testing.T) { ctx := t.Context() + inspectOpts := mobyclient.ContainerInspectOptions{} notFound := cerrdefs.ErrNotFound.WithMessage("No such container") boom := errors.New("daemon unreachable") - newCR := func(id string) (*containerReference, *mockDockerClient) { - client := &mockDockerClient{} - return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client + for _, tc := range []struct { + name, cachedID string + cachedErr error + lookup container.InspectResponse + lookupErr error + listed []container.Summary + wantID string + retryList bool + }{ + {name: "stale id cleared, name lookup empty", cachedID: "stale", cachedErr: notFound, lookupErr: notFound}, + {name: "stale id cleared, name lookup repopulates", cachedID: "stale", cachedErr: notFound, lookup: container.InspectResponse{ID: "fresh", Name: "/job-1"}, wantID: "fresh"}, + {name: "id match falls back to listing", lookup: container.InspectResponse{ID: "job-1abc", Name: "/somebody-else"}, listed: []container.Summary{{ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh"}, + {name: "live id kept", cachedID: "live", wantID: "live"}, + {name: "transient inspect error trusts cache", cachedID: "live", cachedErr: boom, wantID: "live"}, + {name: "name lookup error falls back to listing", lookupErr: boom, listed: []container.Summary{{ID: "other", Names: []string{"/somebody-else"}}, {ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh", retryList: true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &mockDockerClient{} + cr := &containerReference{id: tc.cachedID, cli: client, input: &NewContainerInput{Name: "job-1"}} + if tc.cachedID != "" { + client.On("ContainerInspect", ctx, tc.cachedID, inspectOpts).Return(mobyclient.ContainerInspectResult{}, tc.cachedErr) + } + if tc.cachedID == "" || cerrdefs.IsNotFound(tc.cachedErr) { + client.On("ContainerInspect", ctx, "job-1", inspectOpts).Return(mobyclient.ContainerInspectResult{Container: tc.lookup}, tc.lookupErr) + } + if tc.listed != nil { + client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{Items: tc.listed}, nil).Once() + } + require.NoError(t, cr.find()(ctx)) + assert.Equal(t, tc.wantID, cr.id) + if tc.retryList { + client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, boom) + cr.id = "" + require.ErrorIs(t, cr.find()(ctx), boom) + } + client.AssertExpectations(t) + }) } - inspectOpts := mobyclient.ContainerInspectOptions{} - - t.Run("stale id cleared, name lookup empty", func(t *testing.T) { - cr, client := newCR("stale") - client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound) - client.On("ContainerInspect", ctx, "job-1", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound) - require.NoError(t, cr.find()(ctx)) - assert.Empty(t, cr.id) - client.AssertExpectations(t) - }) - - t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) { - cr, client := newCR("stale") - client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound) - client.On("ContainerInspect", ctx, "job-1", inspectOpts). - Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{ID: "fresh", Name: "/job-1"}}, nil) - require.NoError(t, cr.find()(ctx)) - assert.Equal(t, "fresh", cr.id) - client.AssertExpectations(t) - }) - - t.Run("id match falls back to listing", func(t *testing.T) { - cr, client := newCR("") - client.On("ContainerInspect", ctx, "job-1", inspectOpts). - Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{ID: "job-1abc", Name: "/somebody-else"}}, nil) - client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{Items: []container.Summary{ - {ID: "fresh", Names: []string{"/job-1"}}, - }}, nil) - require.NoError(t, cr.find()(ctx)) - assert.Equal(t, "fresh", cr.id) - client.AssertExpectations(t) - }) - - t.Run("live id kept", func(t *testing.T) { - cr, client := newCR("live") - client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil) - require.NoError(t, cr.find()(ctx)) - assert.Equal(t, "live", cr.id) - client.AssertExpectations(t) - }) - - t.Run("transient inspect error trusts cache", func(t *testing.T) { - cr, client := newCR("live") - client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom) - require.NoError(t, cr.find()(ctx)) - assert.Equal(t, "live", cr.id) - client.AssertExpectations(t) - }) - - t.Run("name lookup error falls back to listing", func(t *testing.T) { - cr, client := newCR("") - client.On("ContainerInspect", ctx, "job-1", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom) - client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{Items: []container.Summary{ - {ID: "other", Names: []string{"/somebody-else"}}, - {ID: "fresh", Names: []string{"/job-1"}}, - }}, nil).Once() - require.NoError(t, cr.find()(ctx)) - assert.Equal(t, "fresh", cr.id) - client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, boom) - cr.id = "" - require.ErrorIs(t, cr.find()(ctx), boom) - client.AssertExpectations(t) - }) } func TestRunnerArchCachedPerRun(t *testing.T) { @@ -568,9 +506,6 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) { } } -// Type assert containerReference implements ExecutionsEnvironment -var _ ExecutionsEnvironment = &containerReference{} - func TestCheckVolumes(t *testing.T) { testCases := []struct { desc string @@ -665,10 +600,11 @@ func TestCheckVolumes(t *testing.T) { ValidVolumes: tc.validVolumes, }, } - _, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts}) + hostConf := &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts} + cr.sanitizeConfig(ctx, hostConf) assert.Equal(t, tc.expectedBinds, hostConf.Binds) assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts) - assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about + assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) }) } } @@ -688,8 +624,6 @@ func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) { assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source) } -// Both of these are read here, on the runner, so a workflow could read the runner's files -// and environment with them. func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) { hostFile := filepath.Join(t.TempDir(), "host.env") require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600)) @@ -699,16 +633,12 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) { logger := logging.New(logtest.New()) cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}} - _, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) - require.ErrorContains(t, err, "not allowed in a workflow") + require.ErrorContains(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}), "not allowed in a workflow") - // the runner reading its own files is what those options are for cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}} - _, _, err = cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) - require.NoError(t, err) + require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})) } - // a bare name is no longer resolved from the runner's environment, for either source logger := logging.New(logtest.New()) cr := &containerReference{input: &NewContainerInput{ NetworkMode: "bridge", @@ -716,8 +646,8 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) { WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value", }} - config, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) - require.NoError(t, err) + config := &container.Config{} + require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), config, &container.HostConfig{})) assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env) } @@ -754,7 +684,6 @@ func TestSanitizeOptionsHostConfig(t *testing.T) { assert.Equal(t, &container.HostConfig{}, hostConfig) } -// mergeOptions merges both option sources into a bare container, returning the result and its log. func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) { t.Helper() logger, hook := logtest.NewLogger() @@ -766,12 +695,12 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege ValidVolumes: []string{"**"}, }} - _, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{ + hostConfig := &container.HostConfig{ Privileged: privileged, UsernsMode: container.UsernsMode("private"), NetworkMode: container.NetworkMode("bridge"), - }) - require.NoError(t, err) + } + require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, hostConfig)) return hostConfig, hook } @@ -825,23 +754,26 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) { } escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied" - _, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + hostConf := &container.HostConfig{ Binds: []string{escapingPath + ":/mnt"}, - }) + } + cr.sanitizeConfig(ctx, hostConf) assert.Empty(t, hostConf.Binds) linkPath := filepath.Join(allowed, "link") if err := os.Symlink(denied, linkPath); err != nil { t.Skipf("cannot create symlink: %v", err) } - _, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + hostConf = &container.HostConfig{ Binds: []string{linkPath + ":/mnt"}, - }) + } + cr.sanitizeConfig(ctx, hostConf) assert.Empty(t, hostConf.Binds) - _, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + hostConf = &container.HostConfig{ Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"}, - }) + } + cr.sanitizeConfig(ctx, hostConf) assert.Empty(t, hostConf.Binds) } @@ -851,11 +783,12 @@ func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) { for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} { logger, hook := logtest.NewLogger() - _, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{ + sanitized := &container.HostConfig{Mounts: []mount.Mount{ hostConfig.Mounts[0], {Type: mount.TypeVolume, Target: "/anonymous"}, {Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"}, - }}) + }} + (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), sanitized) assert.Equal(t, hostConfig.Mounts, sanitized.Mounts) assert.Len(t, hook.AllRecords(), 2) } @@ -951,11 +884,11 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) { }, } - _, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{ + hostConf := &container.HostConfig{ Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"}, Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}}, - }) - require.NoError(t, err) + } + require.NoError(t, cr.mergeContainerConfigs(ctx, &container.Config{}, hostConf)) assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds) assert.Empty(t, hostConf.Mounts) } @@ -1007,8 +940,7 @@ func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) { NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary", }} - _, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) - require.NoError(t, err) + require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})) assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases) } diff --git a/act/container/host_environment.go b/act/container/host_environment.go index 5ed3b43a..842daa54 100644 --- a/act/container/host_environment.go +++ b/act/container/host_environment.go @@ -7,6 +7,7 @@ package container import ( "archive/tar" "bytes" + "cmp" "context" "errors" "fmt" @@ -64,15 +65,11 @@ func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group { } func (e *HostEnvironment) Create(_, _ []string) common.Executor { - return func(ctx context.Context) error { - return nil - } + return common.NewPipelineExecutor() } func (e *HostEnvironment) Close() common.Executor { - return func(ctx context.Context) error { - return nil - } + return common.NewPipelineExecutor() } func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor { @@ -168,15 +165,11 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin } func (e *HostEnvironment) Pull(_ bool) common.Executor { - return func(ctx context.Context) error { - return nil - } + return common.NewPipelineExecutor() } func (e *HostEnvironment) Start(_ bool) common.Executor { - return func(ctx context.Context) error { - return nil - } + return common.NewPipelineExecutor() } type ptyWriter struct { @@ -193,7 +186,6 @@ func (w *ptyWriter) Write(buf []byte) (int, error) { } if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' { _, _ = w.Out.Write([]byte("\n")) - return n, io.EOF } return n, io.EOF } @@ -242,40 +234,25 @@ func writeKeepAlive(ppty io.Writer) { } } -func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) { - defer func() { - finishLog() - }() - if _, err := io.Copy(writer, ppty); err != nil { - return - } -} - func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor { - return func(ctx context.Context) error { - return nil - } + return common.NewPipelineExecutor() } func getEnvListFromMap(env map[string]string) []string { - envList := make([]string, 0) - for k, v := range env { - envList = append(envList, fmt.Sprintf("%s=%s", k, v)) + envList := make([]string, 0, len(env)) + for key, value := range env { + envList = append(envList, key+"="+value) } return envList } -func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, _, workdir string) error { - envList := getEnvListFromMap(env) - var wd string +func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, workdir string) error { + wd := e.Path if workdir != "" { - if filepath.IsAbs(workdir) { - wd = workdir - } else { + wd = workdir + if !filepath.IsAbs(workdir) { wd = filepath.Join(e.Path, workdir) } - } else { - wd = e.Path } // Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend // does in waitForCommand, so the final line of a command's output is not lost. @@ -288,9 +265,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st cmd := exec.CommandContext(ctx, f) cmd.Path = f cmd.Args = command - cmd.Stdin = nil cmd.Stdout = e.StdOut - cmd.Env = envList + cmd.Env = getEnvListFromMap(env) cmd.Stderr = e.StdOut cmd.Dir = wd cmd.SysProcAttr = process.SysProcAttr(cmdline, false) @@ -322,7 +298,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st writer = &ptyWriter{Out: e.StdOut} var finishLog context.CancelFunc logctx, finishLog = context.WithCancel(context.Background()) - go copyPtyOutput(writer, ppty, finishLog) + go func() { + defer finishLog() + _, _ = io.Copy(writer, ppty) + }() go writeKeepAlive(ppty) } if err := cmd.Start(); err != nil { @@ -357,13 +336,13 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st return err } -func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor { - return e.ExecWithCmdLine(command, "", env, user, workdir) +func (e *HostEnvironment) Exec(command []string, env map[string]string, _user, workdir string) common.Executor { + return e.ExecWithCmdLine(command, "", env, workdir) } -func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor { +func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, workdir string) common.Executor { return func(ctx context.Context) error { - if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil { + if err := e.exec(ctx, command, cmdline, env, workdir); err != nil { select { case <-ctx.Done(): return fmt.Errorf("this step has been cancelled: %w", err) @@ -605,8 +584,7 @@ func (*HostEnvironment) GetPathVariableName() string { } func (e *HostEnvironment) DefaultPathVariable() string { - v, _ := os.LookupEnv(e.GetPathVariableName()) - return v + return os.Getenv(e.GetPathVariableName()) } func (*HostEnvironment) JoinPathVariable(paths ...string) string { @@ -616,30 +594,22 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string { // Reference for Arch values for runner.arch // https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context func goArchToActionArch(arch string) string { - archMapper := map[string]string{ + return cmp.Or(map[string]string{ "x86_64": "X64", "amd64": "X64", "386": "X86", "arm": "ARM", "aarch64": "ARM64", "arm64": "ARM64", - } - if arch, ok := archMapper[arch]; ok { - return arch - } - return arch + }[arch], arch) } func goOsToActionOs(os string) string { - osMapper := map[string]string{ + return cmp.Or(map[string]string{ "linux": "Linux", "darwin": "macOS", "windows": "Windows", - } - if os, ok := osMapper[os]; ok { - return os - } - return os + }[os], os) } func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any { diff --git a/act/container/host_environment_test.go b/act/container/host_environment_test.go index b50dbb2c..97497b79 100644 --- a/act/container/host_environment_test.go +++ b/act/container/host_environment_test.go @@ -10,7 +10,6 @@ import ( "context" "io" "os" - "path" "path/filepath" "runtime" "strings" @@ -24,7 +23,6 @@ import ( "github.com/stretchr/testify/require" ) -// Type assert HostEnvironment implements ExecutionsEnvironment var _ ExecutionsEnvironment = &HostEnvironment{} func TestActionPlatformNames(t *testing.T) { @@ -61,32 +59,20 @@ func TestCopyDir(t *testing.T) { } func TestGetContainerArchive(t *testing.T) { - dir := t.TempDir() ctx := t.Context() - e := &HostEnvironment{ - Path: filepath.Join(dir, "path"), - TmpDir: filepath.Join(dir, "tmp"), - ToolCache: filepath.Join(dir, "tool_cache"), - ActPath: filepath.Join(dir, "act_path"), - StdOut: os.Stdout, - Workdir: path.Join("testdata", "scratch"), - } - _ = os.MkdirAll(e.Path, 0o700) - _ = os.MkdirAll(e.TmpDir, 0o700) - _ = os.MkdirAll(e.ToolCache, 0o700) - _ = os.MkdirAll(e.ActPath, 0o700) + e := &HostEnvironment{Path: t.TempDir()} expectedContent := []byte("sdde/7sh") err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) archive, err := e.GetContainerArchive(ctx, e.Path) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) defer archive.Close() reader := tar.NewReader(archive) h, err := reader.Next() - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) assert.Equal(t, "action.yml", h.Name) content, err := io.ReadAll(reader) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) assert.Equal(t, expectedContent, content) _, err = reader.Next() assert.ErrorIs(t, err, io.EOF) @@ -96,19 +82,8 @@ func TestHostEnvironmentExecExitCode(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses POSIX shell") } - dir := t.TempDir() ctx := t.Context() - e := &HostEnvironment{ - Path: filepath.Join(dir, "path"), - TmpDir: filepath.Join(dir, "tmp"), - ToolCache: filepath.Join(dir, "tool_cache"), - ActPath: filepath.Join(dir, "act_path"), - StdOut: io.Discard, - Workdir: filepath.Join(dir, "path"), - } - for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} { - assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup - } + e := &HostEnvironment{Path: t.TempDir(), StdOut: io.Discard} err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx) var exitErr ExitCodeError @@ -130,20 +105,12 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) { {name: "on", allocPTY: true, expect: "TTY"}, } { t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() buf := &bytes.Buffer{} e := &HostEnvironment{ - Path: filepath.Join(dir, "path"), - TmpDir: filepath.Join(dir, "tmp"), - ToolCache: filepath.Join(dir, "tool_cache"), - ActPath: filepath.Join(dir, "act_path"), + Path: t.TempDir(), StdOut: buf, - Workdir: filepath.Join(dir, "path"), AllocatePTY: tc.allocPTY, } - for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} { - require.NoError(t, os.MkdirAll(p, 0o700)) - } err := e.Exec( []string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"}, @@ -156,49 +123,26 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) { } } -func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) { - ctx := common.WithLogger(t.Context(), logging.Default()) - base := t.TempDir() - miscRoot := filepath.Join(base, "misc") - path := filepath.Join(miscRoot, "hostexecutor") - require.NoError(t, os.MkdirAll(path, 0o700)) - workdir := filepath.Join(base, "workspace", "owner", "repo") - require.NoError(t, os.MkdirAll(workdir, 0o700)) - - e := &HostEnvironment{ - Path: path, - Workdir: workdir, - CleanUp: func() { - _ = os.RemoveAll(miscRoot) - }, - StdOut: os.Stdout, +func TestHostEnvironmentRemoveWorkdir(t *testing.T) { + for name, owned := range map[string]bool{"preserves caller workdir": false, "cleans owned workdir": true} { + t.Run(name, func(t *testing.T) { + miscRoot, workdir := t.TempDir(), t.TempDir() + e := &HostEnvironment{ + Path: filepath.Join(miscRoot, "hostexecutor"), + Workdir: workdir, + CleanWorkdir: owned, + CleanUp: func() { _ = os.RemoveAll(miscRoot) }, + } + require.NoError(t, os.MkdirAll(e.Path, 0o700)) + require.NoError(t, e.Remove()(t.Context())) + _, err := os.Stat(workdir) + if owned { + assert.ErrorIs(t, err, os.ErrNotExist) + } else { + require.NoError(t, err) + } + }) } - require.NoError(t, e.Remove()(ctx)) - _, err := os.Stat(workdir) - require.NoError(t, err) -} - -func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) { - ctx := common.WithLogger(t.Context(), logging.Default()) - base := t.TempDir() - miscRoot := filepath.Join(base, "misc") - path := filepath.Join(miscRoot, "hostexecutor") - require.NoError(t, os.MkdirAll(path, 0o700)) - workdir := filepath.Join(base, "workspace", "123", "owner", "repo") - require.NoError(t, os.MkdirAll(workdir, 0o700)) - - e := &HostEnvironment{ - Path: path, - Workdir: workdir, - CleanWorkdir: true, - CleanUp: func() { - _ = os.RemoveAll(miscRoot) - }, - StdOut: os.Stdout, - } - require.NoError(t, e.Remove()(ctx)) - _, err := os.Stat(workdir) - assert.ErrorIs(t, err, os.ErrNotExist) } func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) { @@ -211,9 +155,6 @@ func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) { <-release return nil } - // removeAllWithContext intentionally leaks the delete goroutine on timeout, - // and that goroutine still references removeAll. Unblock it and wait for it - // to return before restoring the var, so the restore can't race the read. t.Cleanup(func() { close(release) <-stubDone @@ -227,87 +168,42 @@ func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) { require.ErrorIs(t, err, context.DeadlineExceeded) } -// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled -// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or -// an unresponsive mount) wedging the runner slot forever at "Cleaning up -// container". Remove must time out the callback and complete job teardown. -func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) { - // Keep the suite fast: shrink the per-phase teardown timeout for this test. - orig := hostCleanupTimeout - hostCleanupTimeout = 100 * time.Millisecond - t.Cleanup(func() { hostCleanupTimeout = orig }) - - ctx := common.WithLogger(t.Context(), logging.Default()) - base := t.TempDir() - path := filepath.Join(base, "misc", "hostexecutor") - require.NoError(t, os.MkdirAll(path, 0o700)) - - release := make(chan struct{}) - t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end - - e := &HostEnvironment{ - Path: path, - CleanUp: func() { - <-release // simulate a delete syscall stuck indefinitely - }, - StdOut: os.Stdout, - } - - done := make(chan error, 1) - go func() { done <- e.Remove()(ctx) }() - - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(10 * time.Second): - t.Fatal("Remove() hung on a stuck CleanUp callback") - } -} - -// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a -// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as -// #1023) wedging job completion after the CleanUp callback has already timed -// out or finished. -func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) { - origTimeout := hostCleanupTimeout - hostCleanupTimeout = 100 * time.Millisecond - t.Cleanup(func() { hostCleanupTimeout = origTimeout }) - - release := make(chan struct{}) - stubDone := make(chan struct{}) - - origRemoveAll := removeAll - removeAll = func(string) error { - defer close(stubDone) - <-release - return nil - } - // The stuck delete goroutine outlives the timed-out Remove and still reads - // removeAll; unblock it and wait before restoring to avoid a restore/read race. - t.Cleanup(func() { - close(release) - <-stubDone - removeAll = origRemoveAll - }) - - ctx := common.WithLogger(t.Context(), logging.Default()) - base := t.TempDir() - path := filepath.Join(base, "misc", "hostexecutor") - require.NoError(t, os.MkdirAll(path, 0o700)) - - e := &HostEnvironment{ - Path: path, - StdOut: os.Stdout, - } - - done := make(chan error, 1) - go func() { done <- e.Remove()(ctx) }() - - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(10 * time.Second): - t.Fatal("Remove() hung on a stuck path removal") +func TestHostEnvironmentRemoveDoesNotHang(t *testing.T) { + for _, phase := range []string{"CleanUp callback", "path removal"} { + t.Run(phase, func(t *testing.T) { + origTimeout := hostCleanupTimeout + hostCleanupTimeout = 100 * time.Millisecond + t.Cleanup(func() { hostCleanupTimeout = origTimeout }) + release, stubDone := make(chan struct{}), make(chan struct{}) + block := func() { + defer close(stubDone) + <-release + } + e := &HostEnvironment{Path: filepath.Join(t.TempDir(), "misc", "hostexecutor"), StdOut: os.Stdout} + if phase == "CleanUp callback" { + e.CleanUp = block + } else { + origRemoveAll := removeAll + removeAll = func(string) error { + block() + return nil + } + t.Cleanup(func() { removeAll = origRemoveAll }) + } + t.Cleanup(func() { + close(release) + <-stubDone + }) + require.NoError(t, os.MkdirAll(e.Path, 0o700)) + done := make(chan error, 1) + go func() { done <- e.Remove()(common.WithLogger(t.Context(), logging.Default())) }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatalf("Remove() hung on a stuck %s", phase) + } + }) } } @@ -315,17 +211,11 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) { t.Run("single dir", func(t *testing.T) { s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`}) assert.Contains(t, s, `$paths = @('C:\workspace\job1')`) - // Self-PID guard is essential — without it the script could taskkill - // the PowerShell process running it. assert.Contains(t, s, "$selfPid = $PID") assert.Contains(t, s, "$_.ProcessId -eq $selfPid") - // Must match both ExecutablePath (binaries from the workspace) and - // CommandLine (system binaries invoked with workspace paths in args), - // both bounded by dir+separator so a name-prefix sibling is spared. assert.Contains(t, s, `$prefix = $p + '\'`) assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix") assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix") - // Each matched PID must be tree-killed, not just stopped. assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F") }) @@ -338,25 +228,16 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) { assert.Contains(t, s, `'C:\work\path'`) assert.Contains(t, s, `'C:\work\workdir'`) assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`) - // Commas between entries — no trailing comma, no leading comma. assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`) }) t.Run("path with single quote is escaped", func(t *testing.T) { - // In PowerShell single-quoted strings the only special char is the - // quote itself, escaped by doubling. A workspace path that ever - // contained `'` would inject a command into the script otherwise. s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`}) assert.Contains(t, s, `'C:\work\it''s\path'`) - // And it must NOT appear unescaped — otherwise the quote would - // terminate the literal early. assert.NotContains(t, s, `'C:\work\it's\path'`) }) t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) { - // A path containing [ ] ? * must be embedded verbatim and matched with - // ordinal String methods, not -like, otherwise the metacharacters would - // be interpreted as wildcards and the leftover process could escape. s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`}) assert.Contains(t, s, `'C:\work\[job]?1'`) assert.NotContains(t, s, "-like") @@ -366,7 +247,6 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) { t.Run("empty dir list still produces a valid script", func(t *testing.T) { s := buildWindowsWorkspaceKillScript(nil) - // Empty array literal — script runs, matches nothing, is a no-op. assert.Contains(t, s, "$paths = @()") assert.Contains(t, s, "Get-CimInstance Win32_Process") }) diff --git a/act/container/linux_container_environment_extensions.go b/act/container/linux_container_environment_extensions.go index 1c21ab39..d8d7ca33 100644 --- a/act/container/linux_container_environment_extensions.go +++ b/act/container/linux_container_environment_extensions.go @@ -16,6 +16,8 @@ import ( type LinuxContainerEnvironmentExtensions struct{} +var windowsPathRegex = regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`) + // Resolves the equivalent host path inside the container // This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject // For use in docker volumes and binds @@ -31,8 +33,6 @@ func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string return "" } - // Test if the path is a windows path - windowsPathRegex := regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`) windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath) // Return as-is if no match @@ -43,11 +43,7 @@ func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string // Convert to WSL2-compatible path if it is a windows path // NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows // based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work. - driveLetter := strings.ToLower(windowsPathComponents[1]) - translatedPath := strings.ReplaceAll(windowsPathComponents[2], `\`, `/`) - // Should make something like /mnt/c/Users/person/My Folder/MyActProject - result := strings.Join([]string{"/mnt", driveLetter, translatedPath}, `/`) - return result + return "/mnt/" + strings.ToLower(windowsPathComponents[1]) + "/" + strings.ReplaceAll(windowsPathComponents[2], `\`, `/`) } func (*LinuxContainerEnvironmentExtensions) GetActPath() string { diff --git a/act/container/linux_container_environment_extensions_test.go b/act/container/linux_container_environment_extensions_test.go index 304aa7e1..7a5c8c6e 100644 --- a/act/container/linux_container_environment_extensions_test.go +++ b/act/container/linux_container_environment_extensions_test.go @@ -5,15 +5,13 @@ package container import ( - "fmt" "os" "runtime" "strings" "testing" - "gitea.com/gitea/runner/act/common/logging" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestContainerPath(t *testing.T) { @@ -23,42 +21,27 @@ func TestContainerPath(t *testing.T) { workDir string } - linuxcontainerext := &LinuxContainerEnvironmentExtensions{} - + cwd, err := os.Getwd() + require.NoError(t, err) + tests := []containerPathJob{ + {"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""}, + {"/home/act", `/home/act/`, ""}, + {cwd, ".", ""}, + } if runtime.GOOS == "windows" { - cwd, err := os.Getwd() - if err != nil { - logging.Error(err) - } - rootDrive := os.Getenv("SystemDrive") - rootDriveLetter := strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "") - for _, v := range []containerPathJob{ + tests = []containerPathJob{ {"/mnt/c/Users/act/go/src/github.com/nektos/act", "C:\\Users\\act\\go\\src\\github.com\\nektos\\act\\", ""}, {"/mnt/f/work/dir", `F:\work\dir`, ""}, {"/mnt/c/windows/to/unix", "windows\\to\\unix", rootDrive + "\\"}, - {fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", rootDrive + "\\"}, - } { - if v.workDir != "" { - t.Chdir(v.workDir) - } - - assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath)) + {"/mnt/" + strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "") + "/act", "act", rootDrive + "\\"}, } - - t.Chdir(cwd) - } else { - cwd, err := os.Getwd() - if err != nil { - logging.Error(err) - } - for _, v := range []containerPathJob{ - {"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""}, - {"/home/act", `/home/act/`, ""}, - {cwd, ".", ""}, - } { - assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath)) + } + for _, test := range tests { + if test.workDir != "" { + t.Chdir(test.workDir) } + assert.Equal(t, test.destinationPath, (&LinuxContainerEnvironmentExtensions{}).ToContainerPath(test.sourcePath)) } } @@ -67,5 +50,4 @@ type typeAssertMockContainer struct { LinuxContainerEnvironmentExtensions } -// Type assert Container + LinuxContainerEnvironmentExtensions implements ExecutionsEnvironment var _ ExecutionsEnvironment = &typeAssertMockContainer{} diff --git a/act/container/parse_env_file.go b/act/container/parse_env_file.go index fe1761e7..01064580 100644 --- a/act/container/parse_env_file.go +++ b/act/container/parse_env_file.go @@ -75,7 +75,6 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex if err := s.Err(); err != nil { return fmt.Errorf("reading env file: %w", err) } - env = &localEnv return nil } } diff --git a/act/container/parse_env_file_test.go b/act/container/parse_env_file_test.go index 28115b5e..4f49375a 100644 --- a/act/container/parse_env_file_test.go +++ b/act/container/parse_env_file_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/text/encoding" "golang.org/x/text/encoding/unicode" ) @@ -22,42 +21,40 @@ func newTestHostEnv(t *testing.T) (*HostEnvironment, string) { return e, filepath.Join(e.Path, "envfile") } -func TestParseEnvFileSingleLine(t *testing.T) { - e, envPath := newTestHostEnv(t) - require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600)) - - env := map[string]string{} - require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) - assert.Equal(t, "bar", env["FOO"]) - assert.Equal(t, "qux", env["BAZ"]) -} - -func TestParseEnvFileMultiLine(t *testing.T) { - e, envPath := newTestHostEnv(t) - content := "FOO< strings.LastIndexAny(file, `:\/`) } func findExecutable(file string, exts []string) (string, error) { if len(exts) == 0 { return file, chkStat(file) } - if hasExt(file) { - if chkStat(file) == nil { - return file, nil - } + if hasExt(file) && chkStat(file) == nil { + return file, nil } for _, e := range exts { if f := file + e; chkStat(f) == nil { diff --git a/act/runner/action.go b/act/runner/action.go index 66594102..58a6b5a0 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -8,7 +8,6 @@ import ( "context" "crypto/rand" "crypto/sha256" - "embed" "encoding/hex" "errors" "fmt" @@ -26,6 +25,8 @@ import ( "gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/container" + _ "embed" + "gitea.dev/actionslib/pkg/model" "github.com/kballard/go-shellquote" ) @@ -47,11 +48,13 @@ type fileWriter func(filename string, data []byte, perm fs.FileMode) error type runAction func(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor //go:embed res/trampoline.js -var trampoline embed.FS +var trampoline string var ( ContainerImageExistsLocally = container.ImageExistsLocally ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor + dockerActionNamePattern = regexp.MustCompile("[^a-zA-Z0-9]") + inputEnvPattern = regexp.MustCompile("[^A-Z0-9-]") ) func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { @@ -85,38 +88,31 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath logger.Debugf("Using synthetic action %v for Dockerfile", action) return action, nil } - if step.With != nil { - if val, ok := step.With["args"]; ok { - var b []byte - if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil { - return nil, err - } - err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o644) - if err2 != nil { - return nil, err2 - } - action := &model.Action{ - Name: "(Synthetic)", - Inputs: map[string]model.Input{ - "cwd": { - Description: "(Actual working directory)", - Required: false, - Default: filepath.Join(actionDir, actionPath), - }, - "command": { - Description: "(Actual program)", - Required: false, - Default: val, - }, - }, - Runs: model.ActionRuns{ - Using: "node12", - Main: "trampoline.js", - }, - } - logger.Debugf("Using synthetic action %v", action) - return action, nil + if val, ok := step.With["args"]; ok { + if err := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), []byte(trampoline), 0o644); err != nil { + return nil, err } + action := &model.Action{ + Name: "(Synthetic)", + Inputs: map[string]model.Input{ + "cwd": { + Description: "(Actual working directory)", + Required: false, + Default: filepath.Join(actionDir, actionPath), + }, + "command": { + Description: "(Actual program)", + Required: false, + Default: val, + }, + }, + Runs: model.ActionRuns{ + Using: "node12", + Main: "trampoline.js", + }, + } + logger.Debugf("Using synthetic action %v", action) + return action, nil } } } @@ -125,8 +121,7 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath } defer closer.Close() - action, err := model.ReadAction(reader) - return action, err + return model.ReadAction(reader) } func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error { @@ -166,7 +161,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction return func(ctx context.Context) error { logger := common.Logger(ctx) actionPath := "" - if remoteAction != nil && remoteAction.Path != "" { + if remoteAction != nil { actionPath = remoteAction.Path } @@ -178,7 +173,6 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction return err } - actionLocation := path.Join(actionDir, actionPath) actionName, containerActionDir := getContainerActionPaths(step) logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) @@ -196,7 +190,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx) case x.IsDocker(): - location := actionLocation + location := path.Join(actionDir, actionPath) if remoteAction == nil { location = containerActionDir } @@ -215,12 +209,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction rc.ApplyExtraPath(ctx, step.getEnv()) execFileName := action.Runs.Main + ".out" - buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Main} - execArgs := []string{filepath.Join(containerActionDir, execFileName)} - return common.NewPipelineExecutor( - rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), - rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), + rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Main}, *step.getEnv(), "", containerActionDir), + rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""), )(ctx) default: return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{ @@ -259,16 +250,14 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin name = path.Join(repository, actionName) } // The human-readable name is sanitized by collapsing every non-alphanumeric character to "-". - sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-") + sanitized := dockerActionNamePattern.ReplaceAllString(name, "-") if localAction { // For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository. sum := sha256.Sum256([]byte(repository + "\x00" + actionName)) sanitized += "-" + hex.EncodeToString(sum[:])[:12] } // "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names - image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest") - image = "act-" + strings.TrimLeft(image, "-") - return strings.ToLower(image) + return strings.ToLower("act-" + strings.TrimLeft(sanitized+"-dockeraction:latest", "-")) } // One image per step and job: its stages share a build, and concurrent jobs never run each other's. @@ -485,20 +474,15 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo } func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { - state, ok := rc.IntraActionState[step.getStepModel().ID] - if ok { - for name, value := range state { - envName := "STATE_" + name - (*env)[envName] = value - } + for name, value := range rc.IntraActionState[step.getStepModel().ID] { + (*env)["STATE_"+name] = value } } func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error { eval := rc.NewExpressionEvaluator(ctx) for inputID, input := range action.Inputs { - envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") - envKey = "INPUT_" + envKey + envKey := "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_") if _, ok := (*env)[envKey]; !ok { var err error if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil { @@ -637,12 +621,9 @@ func runPreStep(step actionStep) common.Executor { rc.ApplyExtraPath(ctx, step.getEnv()) execFileName := action.Runs.Pre + ".out" - buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Pre} - execArgs := []string{filepath.Join(containerActionDir, execFileName)} - return common.NewPipelineExecutor( - rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), - rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), + rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Pre}, *step.getEnv(), "", containerActionDir), + rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""), )(ctx) default: return nil @@ -653,7 +634,7 @@ func runPreStep(step actionStep) common.Executor { func shouldRunPostStep(step actionStep) common.Conditional { return func(ctx context.Context) bool { log := common.Logger(ctx) - stepResults := step.getRunContext().getStepsContext() + stepResults := step.getRunContext().StepResults stepResult := stepResults[step.getStepModel().ID] if stepResult == nil { @@ -731,12 +712,9 @@ func runPostStep(step actionStep) common.Executor { rc.ApplyExtraPath(ctx, step.getEnv()) execFileName := action.Runs.Post + ".out" - buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Post} - execArgs := []string{filepath.Join(containerActionDir, execFileName)} - return common.NewPipelineExecutor( - rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), - rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), + rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Post}, *step.getEnv(), "", containerActionDir), + rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""), )(ctx) default: diff --git a/act/runner/action_composite.go b/act/runner/action_composite.go index 0181669e..8f4f1d48 100644 --- a/act/runner/action_composite.go +++ b/act/runner/action_composite.go @@ -8,7 +8,6 @@ import ( "context" "errors" "fmt" - "regexp" "slices" "strconv" "strings" @@ -32,8 +31,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step ee := parent.NewStepExpressionEvaluator(ctx, step) for inputID, input := range step.getActionModel().Inputs { - envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") - envKey = "INPUT_" + strings.ToUpper(envKey) + envKey := "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_") // lookup if key is defined in the step but the already // evaluated value from the environment @@ -211,19 +209,16 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps { } stepID := step.getStepModel().ID - stepPre := rc.newCompositeCommandExecutor(step.pre()) - preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID)) + preSteps = append(preSteps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.pre()), stepID)) steps = append(steps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.main()), stepID)) // run the post executor in reverse order + stepPost := rc.newCompositeCommandExecutor(step.post()) if postExecutor != nil { - stepPost := rc.newCompositeCommandExecutor(step.post()) - postExecutor = newCompositeStepLogExecutor(stepPost.Finally(postExecutor), stepID) - } else { - stepPost := rc.newCompositeCommandExecutor(step.post()) - postExecutor = newCompositeStepLogExecutor(stepPost, stepID) + stepPost = stepPost.Finally(postExecutor) } + postExecutor = newCompositeStepLogExecutor(stepPost, stepID) } steps = append(steps, common.JobError) @@ -250,12 +245,12 @@ func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common. ctx = WithCompositeStepLogger(ctx, stepID) logger := common.Logger(ctx) err := runStep(ctx) + if err == nil { + err = ctx.Err() + } if err != nil { logger.Errorf("##[error]%s", EscapeCommandData(err.Error())) common.SetJobError(ctx, err) - } else if ctx.Err() != nil { - logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error())) - common.SetJobError(ctx, ctx.Err()) } return nil } diff --git a/act/runner/action_composite_test.go b/act/runner/action_composite_test.go index 9d223fc3..6d12af1b 100644 --- a/act/runner/action_composite_test.go +++ b/act/runner/action_composite_test.go @@ -71,9 +71,9 @@ func TestAppendUniqueMasks(t *testing.T) { want []string }{ { - name: "appends new masks", + name: "appends each new mask once", dst: []string{"a"}, - src: []string{"b", "c"}, + src: []string{"b", "c", "b", "a"}, want: []string{"a", "b", "c"}, }, { @@ -82,12 +82,6 @@ func TestAppendUniqueMasks(t *testing.T) { src: []string{"a", "b"}, want: []string{"a", "b"}, }, - { - name: "deduplicates within src", - dst: []string{"a"}, - src: []string{"b", "b", "a"}, - want: []string{"a", "b"}, - }, { name: "empty src leaves dst unchanged", dst: []string{"a"}, @@ -103,26 +97,6 @@ func TestAppendUniqueMasks(t *testing.T) { } } -// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of -// the parent's Masks slice observed with nested/repeated composite actions. A -// composite RunContext is seeded with its parent's masks and the whole seeded -// slice was previously appended back into the parent, doubling its length on -// every composite action. -func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) { - parentMasks := []string{"secret"} - - for range 20 { - // compositeRC.Masks starts as a copy of the parent's masks (it is - // seeded with parent.Masks in newCompositeRunContext). - compositeMasks := make([]string, len(parentMasks)) - copy(compositeMasks, parentMasks) - - parentMasks = appendUniqueMasks(parentMasks, compositeMasks) - } - - assert.Equal(t, []string{"secret"}, parentMasks) -} - func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) { failStep := func(rc *RunContext) { rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure} diff --git a/act/runner/action_test.go b/act/runner/action_test.go index 795dd70d..ddccb2d5 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -126,7 +126,7 @@ runs: return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil } - writeFile := func(filename string, data []byte, perm fs.FileMode) error { + writeFile := func(filename string, _data []byte, perm fs.FileMode) error { assert.Equal(t, "actionDir/actionPath/trampoline.js", filename) assert.Equal(t, fs.FileMode(0o644), perm) return nil @@ -134,7 +134,7 @@ runs: action, err := readActionImpl(t.Context(), tt.step, "actionDir", "actionPath", readFile, writeFile) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) assert.Equal(t, tt.expected, action) assert.Equal(t, tt.filename != "", closed) @@ -144,94 +144,27 @@ runs: func TestActionRunner(t *testing.T) { t.Chdir(t.TempDir()) - table := []struct { + for _, tt := range []struct { name string - step actionStep + inputs map[string]model.Input + state map[string]string expectedEnv map[string]string }{ { - name: "with-input", - step: &stepActionRemote{ - Step: &model.Step{ - Uses: "org/repo/path@ref", - }, - RunContext: &RunContext{ - Config: &Config{}, - Run: &model.Run{ - JobID: "job", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "job": { - Name: "job", - }, - }, - }, - }, - }, - action: &model.Action{ - Inputs: map[string]model.Input{ - "key": { - Default: "default value", - }, - }, - Runs: model.ActionRuns{ - Using: "node16", - }, - }, - env: map[string]string{}, - remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"), - }, + name: "with-input", + inputs: map[string]model.Input{"key": {Default: "default value"}}, expectedEnv: map[string]string{"INPUT_KEY": "default value"}, }, { - name: "restore-saved-state", - step: &stepActionRemote{ - Step: &model.Step{ - ID: "step", - Uses: "org/repo/path@ref", - }, - RunContext: &RunContext{ - ActionPath: "path", - Config: &Config{}, - Run: &model.Run{ - JobID: "job", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "job": { - Name: "job", - }, - }, - }, - }, - CurrentStep: "post-step", - StepResults: map[string]*model.StepResult{ - "step": {}, - }, - IntraActionState: map[string]map[string]string{ - "step": { - "name": "state value", - }, - }, - }, - action: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - }, - }, - env: map[string]string{}, - remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"), - }, + name: "restore-saved-state", + state: map[string]string{"name": "state value"}, expectedEnv: map[string]string{"STATE_name": "state value"}, }, - } - - for _, tt := range table { + } { t.Run(tt.name, func(t *testing.T) { - ctx := t.Context() - cm := &containerMock{} containerActionDir := "/var/run/act/actions/" + model.UsesHash("/org/repo/path@ref") + "/" - cm.On("CopyDir", containerActionDir, "dir/", false, true).Return(func(ctx context.Context) error { return nil }) + cm.On("CopyDir", containerActionDir, "dir/", false, true).Return(func(_ctx context.Context) error { return nil }) envMatcher := mock.MatchedBy(func(env map[string]string) bool { for k, v := range tt.expectedEnv { @@ -241,14 +174,22 @@ func TestActionRunner(t *testing.T) { } return true }) + cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(_ctx context.Context) error { return nil }) - cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) - - tt.step.getRunContext().JobContainer = cm - - err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx) - - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + remoteAction := mustNewRemoteAction(t, "org/repo/path@ref") + step := &stepActionRemote{ + Step: &model.Step{ID: "step", Uses: "org/repo/path@ref"}, + RunContext: &RunContext{ + Config: &Config{}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, + IntraActionState: map[string]map[string]string{"step": tt.state}, + JobContainer: cm, + }, + action: &model.Action{Inputs: tt.inputs, Runs: model.ActionRuns{Using: "node16"}}, + env: map[string]string{}, + remoteAction: remoteAction, + } + require.NoError(t, runActionImpl(step, "dir", remoteAction)(t.Context())) cm.AssertExpectations(t) }) } @@ -322,7 +263,6 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) { newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") - // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers. assert.Empty(t, captured.Username) assert.Empty(t, captured.Password) assert.True(t, captured.AutoRemove) @@ -478,15 +418,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { } func TestDockerActionImageTag(t *testing.T) { - // Remote actions already carry a unique, ref-scoped actionName (the uses - // hash), so the tag must be left untouched for backwards compatibility. assert.Equal(t, "act-abc123-dockeraction:latest", dockerActionImageTag("owner/repo", "abc123", false), ) - // Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName). - // See https://gitea.com/gitea/runner/issues/1039. assert.Equal(t, "act-owner-repo-baca2daaa2fe-dockeraction:latest", dockerActionImageTag("owner/repo", "./", true), @@ -496,16 +432,11 @@ func TestDockerActionImageTag(t *testing.T) { dockerActionImageTag("owner/repo", "./sub", true), ) - // Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix. - // The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another. collisions := [][2]struct { repoName string actionName string }{ - // Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c". {{"a/b-c", "./"}, {"a-b/c", "./"}}, - // A repository's root action vs another repository's sub-path action: - // "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a". {{"owner/repo-a", "./"}, {"owner/repo", "./a"}}, } for _, c := range collisions { @@ -517,7 +448,6 @@ func TestDockerActionImageTag(t *testing.T) { ) } - // Distinct local actions within the same repository keep distinct tags. assert.NotEqual(t, dockerActionImageTag("owner/repo", "./", true), dockerActionImageTag("owner/repo", "./sub", true), diff --git a/act/runner/cancellation_test.go b/act/runner/cancellation_test.go index c370dd59..1c593e48 100644 --- a/act/runner/cancellation_test.go +++ b/act/runner/cancellation_test.go @@ -66,145 +66,93 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) { assert.Equal(t, "cancelled", rc.Run.Job().Result) } -// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does -// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step -// still runs (so a main-stage always() step is reached), it runs under a fresh, -// non-cancelled context, and the job is marked cancelled. The interrupt error is still -// propagated so callers up the chain see the cancellation. -func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) { - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }) +func TestMainStepsExecutorRunsAlwaysStepsAfterInterruption(t *testing.T) { + for name, wantError := range map[string]error{"cancel": context.Canceled, "timeout": context.DeadlineExceeded} { + t.Run(name, func(t *testing.T) { + rc := createIfTestRunContext(map[string]*model.Job{ + "job1": createJob(t, `runs-on: ubuntu-latest`, ""), + }) + var ctx context.Context + var interrupt func() + if wantError == context.Canceled { + ctx, interrupt = context.WithCancel(t.Context()) + defer interrupt() + } else { + deadline := newControllableDeadlineContext(t.Context()) + ctx, interrupt = deadline, deadline.expire + } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() + var ran []string + var laterStepCtxErr error + err := newMainStepsExecutor(rc, []common.Executor{ + func(_ctx context.Context) error { + ran = append(ran, "step1") + interrupt() + return nil + }, + func(ctx context.Context) error { + ran = append(ran, "always-step") + laterStepCtxErr = ctx.Err() + return nil + }, + })(ctx) - var ran []string - var laterStepCtxErr error - steps := []common.Executor{ - func(_ context.Context) error { - ran = append(ran, "step1") - cancel() // server cancellation lands while step1 runs - return nil - }, - func(c context.Context) error { - ran = append(ran, "always-step") - laterStepCtxErr = c.Err() - return nil - }, + require.ErrorIs(t, err, wantError) + assert.Equal(t, []string{"step1", "always-step"}, ran) + require.NoError(t, laterStepCtxErr) + assert.Equal(t, wantError == context.Canceled, rc.jobCancelled) + if wantError == context.DeadlineExceeded { + assert.True(t, rc.jobFailed) + assert.Equal(t, "failure", rc.getJobContext().Status) + } + }) } - - err := newMainStepsExecutor(rc, steps)(ctx) - - require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated") - assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel") - require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context") - assert.True(t, rc.jobCancelled, "the job is marked cancelled") } -// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path. -// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled), -// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success. -func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) { - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }) +func TestStepsExecutorRunsMainStepsAfterPreInterruption(t *testing.T) { + for name, tt := range map[string]struct { + cancel bool + preErr error + wantError error + }{ + "cancel": {cancel: true, wantError: context.Canceled}, + "failure": {preErr: assert.AnError, wantError: assert.AnError}, + } { + t.Run(name, func(t *testing.T) { + rc := createIfTestRunContext(map[string]*model.Job{ + "job1": createJob(t, `runs-on: ubuntu-latest`, ""), + }) + ctx := t.Context() + var cancel context.CancelFunc + if tt.cancel { + ctx, cancel = context.WithCancel(ctx) + defer cancel() + } - ctx := newControllableDeadlineContext(t.Context()) + var ran []string + var mainStepCtxErr error + err := newStepsExecutor(rc, []common.Executor{ + func(_ctx context.Context) error { + ran = append(ran, "pre1") + if tt.cancel { + cancel() + } + return tt.preErr + }, + }, []common.Executor{ + func(ctx context.Context) error { + ran = append(ran, "always-step") + mainStepCtxErr = ctx.Err() + return nil + }, + })(ctx) - var ran []string - var laterStepCtxErr error - steps := []common.Executor{ - func(context.Context) error { - ran = append(ran, "step1") - ctx.expire() - return nil - }, - func(c context.Context) error { - ran = append(ran, "always-step") - laterStepCtxErr = c.Err() - return nil - }, + require.ErrorIs(t, err, tt.wantError) + assert.Equal(t, []string{"pre1", "always-step"}, ran) + require.NoError(t, mainStepCtxErr) + assert.Equal(t, tt.cancel, rc.jobCancelled) + }) } - - err := newMainStepsExecutor(rc, steps)(ctx) - - require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated") - assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout") - require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context") - assert.True(t, rc.jobFailed, "a job timeout marks the job failed") - assert.False(t, rc.jobCancelled, "a timeout is not a cancellation") - - // The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run. - assert.Equal(t, "failure", rc.getJobContext().Status) -} - -// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the -// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor, -// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context), -// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)` -// short-circuit skipped the main steps entirely when a pre step was cancelled. -func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) { - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }) - - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - - var ran []string - var mainStepCtxErr error - preSteps := []common.Executor{ - func(_ context.Context) error { - ran = append(ran, "pre1") - cancel() // server cancellation lands during the pre phase - return nil - }, - } - steps := []common.Executor{ - func(c context.Context) error { - ran = append(ran, "always-step") - mainStepCtxErr = c.Err() - return nil - }, - } - - err := newStepsExecutor(rc, preSteps, steps)(ctx) - - require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated") - assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel") - require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context") - assert.True(t, rc.jobCancelled, "the job is marked cancelled") -} - -// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon -// the main steps: they still run (so a main-stage always()/failure() step is reached), and the -// pre-step error is propagated so the job is reported as failed. The main steps' own `if` -// evaluation is what skips success()-default steps, so running them here is safe. -func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) { - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }) - - var ran []string - preSteps := []common.Executor{ - func(_ context.Context) error { - ran = append(ran, "pre1") - return assert.AnError - }, - } - steps := []common.Executor{ - func(_ context.Context) error { - ran = append(ran, "always-step") - return nil - }, - } - - err := newStepsExecutor(rc, preSteps, steps)(t.Context()) - - require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated") - assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure") - assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation") } // TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real diff --git a/act/runner/command.go b/act/runner/command.go index 44248788..91112f75 100644 --- a/act/runner/command.go +++ b/act/runner/command.go @@ -15,10 +15,10 @@ import ( ) // parseActionCommand parses like GitHub's runner, but rejects a bare \r GitHub would split at and keeps a value's leading '=' so masks match. -func parseActionCommand(line, resumeCommand string) (command string, properties map[string]string, data string, legacy, ok bool) { +func parseActionCommand(line, resumeCommand string) (command string, properties map[string]string, data string, ok bool) { line = strings.TrimRight(line, "\r\n") if strings.ContainsAny(line, "\r\n") { - return "", nil, "", false, false + return "", nil, "", false } trimmed := strings.TrimLeftFunc(line, unicode.IsSpace) for _, legacy := range []bool{false, true} { @@ -54,9 +54,9 @@ func parseActionCommand(line, resumeCommand string) (command string, properties properties[strings.ToLower(key)] = unescapeProperty(value) } } - return command, properties, unescapeData(data), legacy, true + return command, properties, unescapeData(data), true } - return "", nil, "", false, false + return "", nil, "", false } // commandHandler reports whether a line is logged, keeping per-handler command state as GitHub does. @@ -66,7 +66,7 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler { echo := debug resumeCommand := "" return func(line string) bool { - command, kvPairs, arg, _, ok := parseActionCommand(line, resumeCommand) + command, kvPairs, arg, ok := parseActionCommand(line, resumeCommand) if !ok { return true } @@ -143,7 +143,7 @@ func formatAnnotation(level string, properties map[string]string, message string // serviceCommandHandler applies only ::add-mask::, so a service cannot set the job's env, outputs or state. func (rc *RunContext) serviceCommandHandler(ctx context.Context) common.LineHandler { return func(line string) bool { - command, _, arg, _, ok := parseActionCommand(line, "") + command, _, arg, ok := parseActionCommand(line, "") if !ok || command != "add-mask" { return true } @@ -158,7 +158,9 @@ const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS" // not run, recording the error that fails the step. GitHub disabled both because a step that // echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step. func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool { - if rc.allowUnsecureCommandsOptIn() { + if isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) || + isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) || + isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar]) { return false } @@ -176,14 +178,6 @@ func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) return true } -// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated -// commands, from any env scope, as it can on GitHub. -func (rc *RunContext) allowUnsecureCommandsOptIn() bool { - return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) || - isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) || - isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar]) -} - // isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing. func isTruthyEnv(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "true") @@ -230,17 +224,15 @@ func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, } func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) { - logger := common.Logger(ctx) stepID := rc.CurrentStep - outputName := kvPairs["name"] result, ok := rc.StepResults[stepID] if !ok { - logger.Infof("No outputs registered for step '%s'", stepID) + common.Logger(ctx).Infof("No outputs registered for step '%s'", stepID) return } - result.Outputs[outputName] = arg + result.Outputs[kvPairs["name"]] = arg } func (rc *RunContext) addPath(arg string) { diff --git a/act/runner/command_test.go b/act/runner/command_test.go index 0e627cd2..46ad8f52 100644 --- a/act/runner/command_test.go +++ b/act/runner/command_test.go @@ -24,43 +24,42 @@ func TestParseActionCommand(t *testing.T) { name, line, resumeCommand, command string properties map[string]string data string - legacy, ok bool + ok bool }{ - {"plain", "plain output", "", "", nil, "", false, false}, - {"unknown v2", "::unknown::v", "", "", nil, "", false, false}, - {"missing v2 terminator", "::set-output name=x", "", "", nil, "", false, false}, - {"empty command", "::::", "", "", nil, "", false, false}, - {"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false, false}, - {"missing legacy terminator", "##[debug", "", "", nil, "", false, false}, - {"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false, false}, - {"v2 leading unicode whitespace and line endings", " \t\u2003::set-output name=tab::v\r\n\r\n", "", "set-output", map[string]string{"name": "tab"}, "v", false, true}, - {"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true, true}, - {"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false, false}, - {"v2 delimiter boundaries", "::set-output name=a:b,other=c:d:: a::b ", "", "set-output", map[string]string{"name": "a:b", "other": "c:d"}, " a::b ", false, true}, - {"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true, true}, - {"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", false, true}, - {"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true, true}, - {"values keep their leading equals", "::set-output name==x=y,=key=v,empty=,=x,spaced= =x::v", "", "set-output", map[string]string{"name": "=x=y", "key": "v", "spaced": " =x"}, "v", false, true}, - {"v2 trims whole list only", "::set-output \tfile=x, line=2,name = value \t::v", "", "set-output", map[string]string{"file": "x", " line": "2", "name ": " value"}, "v", false, true}, - {"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true, true}, - {"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", false, true}, - {"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", false, true}, - {"v2 decode table", "::set-output name=%0D%0A%3A%2C%25%253A%252C%250A%2525%3a%2c%0d%0a%3B%5D::%0D%0A%25%3A%2C%3B%5D%250A", "", "set-output", map[string]string{"name": "\r\n:,%%3A%2C%0A%25%3a%2c%0d%0a%3B%5D"}, "\r\n%%3A%2C%3B%5D%0A", false, true}, - {"legacy decode table", "##[set-output name=%3B%0D%0A%5D%25%253B%255D%250A%2525%3b%5d%0d%0a%3A%2C]v%5Dw%3Bx", "", "set-output", map[string]string{"name": ";\r\n]%%3B%5D%0A%25%3b%5d%0d%0a%3A%2C"}, "v]w;x", true, true}, - {"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true, true}, - {"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true, true}, - {"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", false, true}, - {"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false, false}, - {"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", false, true}, - {"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true, true}, - {"different resume token", "::other-token::", "resume-token", "", nil, "", false, false}, + {"plain", "plain output", "", "", nil, "", false}, + {"unknown v2", "::unknown::v", "", "", nil, "", false}, + {"missing v2 terminator", "::set-output name=x", "", "", nil, "", false}, + {"empty command", "::::", "", "", nil, "", false}, + {"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false}, + {"missing legacy terminator", "##[debug", "", "", nil, "", false}, + {"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false}, + {"v2 leading unicode whitespace and line endings", " \t\u2003::set-output name=tab::v\r\n\r\n", "", "set-output", map[string]string{"name": "tab"}, "v", true}, + {"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true}, + {"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false}, + {"v2 delimiter boundaries", "::set-output name=a:b,other=c:d:: a::b ", "", "set-output", map[string]string{"name": "a:b", "other": "c:d"}, " a::b ", true}, + {"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true}, + {"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", true}, + {"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true}, + {"values keep their leading equals", "::set-output name==x=y,=key=v,empty=,=x,spaced= =x::v", "", "set-output", map[string]string{"name": "=x=y", "key": "v", "spaced": " =x"}, "v", true}, + {"v2 trims whole list only", "::set-output \tfile=x, line=2,name = value \t::v", "", "set-output", map[string]string{"file": "x", " line": "2", "name ": " value"}, "v", true}, + {"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true}, + {"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", true}, + {"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", true}, + {"v2 decode table", "::set-output name=%0D%0A%3A%2C%25%253A%252C%250A%2525%3a%2c%0d%0a%3B%5D::%0D%0A%25%3A%2C%3B%5D%250A", "", "set-output", map[string]string{"name": "\r\n:,%%3A%2C%0A%25%3a%2c%0d%0a%3B%5D"}, "\r\n%%3A%2C%3B%5D%0A", true}, + {"legacy decode table", "##[set-output name=%3B%0D%0A%5D%25%253B%255D%250A%2525%3b%5d%0d%0a%3A%2C]v%5Dw%3Bx", "", "set-output", map[string]string{"name": ";\r\n]%%3B%5D%0A%25%3b%5d%0d%0a%3A%2C"}, "v]w;x", true}, + {"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true}, + {"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true}, + {"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", true}, + {"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false}, + {"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", true}, + {"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true}, + {"different resume token", "::other-token::", "resume-token", "", nil, "", false}, } { t.Run(test.name, func(t *testing.T) { - command, properties, data, legacy, ok := parseActionCommand(test.line, test.resumeCommand) + command, properties, data, ok := parseActionCommand(test.line, test.resumeCommand) assert.Equal(t, test.command, command) assert.Equal(t, test.properties, properties) assert.Equal(t, test.data, data) - assert.Equal(t, test.legacy, legacy) assert.Equal(t, test.ok, ok) }) } diff --git a/act/runner/container_mock_test.go b/act/runner/container_mock_test.go index 2e9041a1..f6356214 100644 --- a/act/runner/container_mock_test.go +++ b/act/runner/container_mock_test.go @@ -23,56 +23,44 @@ type containerMock struct { } func (cm *containerMock) Create(capAdd, capDrop []string) common.Executor { - args := cm.Called(capAdd, capDrop) - return args.Get(0).(func(context.Context) error) + return cm.Called(capAdd, capDrop).Get(0).(func(context.Context) error) } func (cm *containerMock) Pull(forcePull bool) common.Executor { - args := cm.Called(forcePull) - return args.Get(0).(func(context.Context) error) + return cm.Called(forcePull).Get(0).(func(context.Context) error) } func (cm *containerMock) Start(attach bool) common.Executor { - args := cm.Called(attach) - return args.Get(0).(func(context.Context) error) + return cm.Called(attach).Get(0).(func(context.Context) error) } func (cm *containerMock) Remove() common.Executor { - args := cm.Called() - return args.Get(0).(func(context.Context) error) + return cm.Called().Get(0).(func(context.Context) error) } func (cm *containerMock) Close() common.Executor { - args := cm.Called() - return args.Get(0).(func(context.Context) error) + return cm.Called().Get(0).(func(context.Context) error) } func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { - args := cm.Called(srcPath, env) - return args.Get(0).(func(context.Context) error) + return cm.Called(srcPath, env).Get(0).(func(context.Context) error) } func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor { - args := cm.Called(destPath, files) - return args.Get(0).(func(context.Context) error) + return cm.Called(destPath, files).Get(0).(func(context.Context) error) } func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor { - args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir) - return args.Get(0).(func(context.Context) error) + return cm.Called(destPath, srcPath, useGitIgnore, skipGitDir).Get(0).(func(context.Context) error) } func (cm *containerMock) Exec(command []string, env map[string]string, user, workdir string) common.Executor { - args := cm.Called(command, env, user, workdir) - return args.Get(0).(func(context.Context) error) + return cm.Called(command, env, user, workdir).Get(0).(func(context.Context) error) } func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { args := cm.Called(ctx, srcPath) - err, hasErr := args.Get(1).(error) - if !hasErr { - err = nil - } + err, _ := args.Get(1).(error) return args.Get(0).(io.ReadCloser), err } diff --git a/act/runner/expression.go b/act/runner/expression.go index 8eb6c329..c872d80e 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -58,8 +58,6 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map } ghc := rc.getGithubContext(ctx) - inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc) - ee := &exprparser.EvaluationEnvironment{ Github: ghc, Env: env, @@ -67,13 +65,13 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map Jobs: &workflowCallResult, // todo: should be unavailable // but required to interpolate/evaluate the step outputs on the job - Steps: rc.getStepsContext(), + Steps: rc.StepResults, Secrets: getWorkflowSecrets(rc), - Vars: getWorkflowVars(ctx, rc), + Vars: rc.Config.Vars, Strategy: strategy, Matrix: rc.Matrix, Needs: exprparser.NeedsContext(rc.Run), - Inputs: inputs, + Inputs: getEvaluatorInputs(rc, rc.actionInputs, ghc), HashFiles: getHashFilesFunction(ctx, rc), } ee.Runner = rc.getRunnerContext(ctx) @@ -105,9 +103,9 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, Github: step.getGithubContext(ctx), Env: *step.getEnv(), Job: jobContext, - Steps: rc.getStepsContext(), + Steps: rc.StepResults, Secrets: getWorkflowSecrets(rc), - Vars: getWorkflowVars(ctx, rc), + Vars: rc.Config.Vars, Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal), Matrix: rc.Matrix, Needs: exprparser.NeedsContext(rc.Run), @@ -127,7 +125,7 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, } func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (any, error) { - hashFiles := func(v []reflect.Value) (any, error) { + return func(v []reflect.Value) (any, error) { if rc.JobContainer != nil { timeed, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() @@ -139,14 +137,12 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect. for i, p := range v { s := p.String() - if i == 0 { - if strings.HasPrefix(s, "--") { - if strings.EqualFold(s, "--follow-symbolic-links") { - followSymlink = true - continue - } - return "", fmt.Errorf("invalid glob option %s, available option: '--follow-symbolic-links'", s) + if i == 0 && strings.HasPrefix(s, "--") { + if strings.EqualFold(s, "--follow-symbolic-links") { + followSymlink = true + continue } + return "", fmt.Errorf("invalid glob option %s, available option: '--follow-symbolic-links'", s) } patterns = append(patterns, s) } @@ -169,20 +165,14 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect. rc.JobContainer.ReplaceLogWriter(stdout, stderr) return nil })(timeed) - output := hout.String() + "\n" + herr.String() - guard := "__OUTPUT__" - outstart := strings.Index(output, guard) - if outstart != -1 { - outstart += len(guard) - outend := strings.Index(output[outstart:], guard) - if outend != -1 { - return output[outstart : outstart+outend], nil + if _, output, found := strings.Cut(hout.String()+"\n"+herr.String(), "__OUTPUT__"); found { + if hash, _, found := strings.Cut(output, "__OUTPUT__"); found { + return hash, nil } } } return "", nil } - return hashFiles } type expressionEvaluator struct { @@ -191,14 +181,15 @@ type expressionEvaluator struct { type ExpressionEvaluator = expressionEvaluator +var addMaskExpression = regexp.MustCompile(`::add-mask::.*`) + func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) { logger := common.Logger(ctx) logger.Debugf("evaluating expression '%s'", in) evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) // evaluated is an any: %t renders everything but a bool as "%!t(string=...)" - printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)") - logger.Debugf("expression '%s' evaluated to '%s'", in, printable) + logger.Debugf("expression '%s' evaluated to '%s'", in, addMaskExpression.ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)")) return evaluated, err } @@ -251,15 +242,13 @@ func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.Gi if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow return maps.Clone(stepInputs) } - inputs := map[string]any{} - // only a called workflow's job itself takes the inputs Gitea resolved, // composite actions copy its Config but have their own `inputs` if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil { - maps.Copy(inputs, rc.Config.WorkflowCallInputs) - return inputs + return maps.Clone(rc.Config.WorkflowCallInputs) } + inputs := map[string]any{} maps.Copy(inputs, rc.workflowCallInputs) switch ghc.EventName { @@ -329,7 +318,3 @@ func getWorkflowSecrets(rc *RunContext) map[string]string { } return rc.Config.Secrets } - -func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string { - return rc.Config.Vars -} diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index 715f028a..3afdfb88 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -85,71 +85,61 @@ func TestEvaluateRunContext(t *testing.T) { ee := rc.NewExpressionEvaluator(t.Context()) tables := []struct { - in string - out any - errMesg string + in string + out any }{ - {" 1 ", 1.0, ""}, - // {"1 + 3", "4", ""}, - // {"(1 + 3) * -2", "-8", ""}, - {"'my text'", "my text", ""}, - {"contains('my text', 'te')", true, ""}, - {"contains('my TEXT', 'te')", true, ""}, - {"contains(fromJSON('[\"my text\"]'), 'te')", false, ""}, - {"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true, ""}, - {"startsWith('hello world', 'He')", true, ""}, - {"endsWith('hello world', 'ld')", true, ""}, - {"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one", ""}, - {"join(fromJSON('[\"hello\"]'),'octocat')", "hello", ""}, - {"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe", ""}, - {"join('hello','mona')", "hello", ""}, - {"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""}, - {"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""}, - {"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar", ""}, - {"(fromJson('{\"foo\":\"bar\"}')).foo", "bar", ""}, - {"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar", ""}, - // github does return an empty string for non-existent files - {"hashFiles('**/non-extant-files')", "", ""}, - {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", "", ""}, - {"hashFiles('**/non.extant.files')", "", ""}, - {"hashFiles('**/non''extant''files')", "", ""}, - {"success()", true, ""}, - {"failure()", false, ""}, - {"always()", true, ""}, - {"cancelled()", false, ""}, - {"github.workflow", "test-workflow", ""}, - {"github.actor", "nektos/act", ""}, - {"github.run_id", "1", ""}, - {"github.run_number", "1", ""}, - {"job.status", "success", ""}, - {"matrix.os", "Linux", ""}, - {"matrix.foo", "bar", ""}, - {"env.key", "value", ""}, - {"secrets.CASE_INSENSITIVE_SECRET", "value", ""}, - {"secrets.case_insensitive_secret", "value", ""}, - {"vars.CASE_INSENSITIVE_VAR", "value", ""}, - {"vars.case_insensitive_var", "value", ""}, - {"format('{{0}}', 'test')", "{0}", ""}, - {"format('{{{0}}}', 'test')", "{test}", ""}, - {"format('}}')", "}", ""}, - {"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}", ""}, - {"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", ""}, - {"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}", ""}, - {"format('{0}', '{1}', 'World')", "{1}", ""}, - {"format('{{{0}', '{1}', 'World')", "{{1}", ""}, + {" 1 ", 1.0}, + {"'my text'", "my text"}, + {"contains('my text', 'te')", true}, + {"contains('my TEXT', 'te')", true}, + {"contains(fromJSON('[\"my text\"]'), 'te')", false}, + {"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true}, + {"startsWith('hello world', 'He')", true}, + {"endsWith('hello world', 'ld')", true}, + {"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one"}, + {"join(fromJSON('[\"hello\"]'),'octocat')", "hello"}, + {"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe"}, + {"join('hello','mona')", "hello"}, + {"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"}, + {"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"}, + {"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar"}, + {"(fromJson('{\"foo\":\"bar\"}')).foo", "bar"}, + {"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar"}, + {"hashFiles('**/non-extant-files')", ""}, + {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", ""}, + {"hashFiles('**/non.extant.files')", ""}, + {"hashFiles('**/non''extant''files')", ""}, + {"success()", true}, + {"failure()", false}, + {"always()", true}, + {"cancelled()", false}, + {"github.workflow", "test-workflow"}, + {"github.actor", "nektos/act"}, + {"github.run_id", "1"}, + {"github.run_number", "1"}, + {"job.status", "success"}, + {"matrix.os", "Linux"}, + {"matrix.foo", "bar"}, + {"env.key", "value"}, + {"secrets.CASE_INSENSITIVE_SECRET", "value"}, + {"secrets.case_insensitive_secret", "value"}, + {"vars.CASE_INSENSITIVE_VAR", "value"}, + {"vars.case_insensitive_var", "value"}, + {"format('{{0}}', 'test')", "{0}"}, + {"format('{{{0}}}', 'test')", "{test}"}, + {"format('}}')", "}"}, + {"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}"}, + {"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}"}, + {"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}"}, + {"format('{0}', '{1}', 'World')", "{1}"}, + {"format('{{{0}', '{1}', 'World')", "{{1}"}, } for _, table := range tables { t.Run(table.in, func(t *testing.T) { - assertObject := assert.New(t) out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) - if table.errMesg == "" { - assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act - assertObject.Equal(table.out, out, table.in) - } else { - assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act - assertObject.Equal(table.errMesg, err.Error(), table.in) - } + require.NoError(t, err) + assert.Equal(t, table.out, out) }) } } @@ -165,33 +155,26 @@ func TestEvaluateStep(t *testing.T) { ee := rc.NewStepExpressionEvaluator(t.Context(), step) tables := []struct { - in string - out any - errMesg string + in string + out any }{ - {"steps.idwithnothing.conclusion", model.StepStatusSuccess.String(), ""}, - {"steps.idwithnothing.outcome", model.StepStatusFailure.String(), ""}, - {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing", ""}, - {"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String(), ""}, - {"steps.id-with-hyphens.outcome", model.StepStatusFailure.String(), ""}, - {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens", ""}, - {"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String(), ""}, - {"steps.id_with_underscores.outcome", model.StepStatusFailure.String(), ""}, - {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""}, - {"inputs.forged", nil, ""}, // INPUT_* env is not an input + {"steps.idwithnothing.conclusion", model.StepStatusSuccess.String()}, + {"steps.idwithnothing.outcome", model.StepStatusFailure.String()}, + {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing"}, + {"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String()}, + {"steps.id-with-hyphens.outcome", model.StepStatusFailure.String()}, + {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens"}, + {"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String()}, + {"steps.id_with_underscores.outcome", model.StepStatusFailure.String()}, + {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores"}, + {"inputs.forged", nil}, } for _, table := range tables { t.Run(table.in, func(t *testing.T) { - assertObject := assert.New(t) out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) - if table.errMesg == "" { - assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act - assertObject.Equal(table.out, out, table.in) - } else { - assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act - assertObject.Equal(table.errMesg, err.Error(), table.in) - } + require.NoError(t, err) + assert.Equal(t, table.out, out) }) } } @@ -283,10 +266,8 @@ func TestInterpolate(t *testing.T) { } func TestGetEvaluatorInputsBoolean(t *testing.T) { - workflows := map[string]string{ - "workflow_call": ` -on: - workflow_call: + workflow := func(event string) string { + return "on:\n " + event + `: inputs: flag: type: boolean @@ -296,20 +277,7 @@ on: default: gitea unset: type: boolean -`, - "workflow_dispatch": ` -on: - workflow_dispatch: - inputs: - flag: - type: boolean - default: true - name: - type: string - default: gitea - unset: - type: boolean -`, +` } tables := []struct { @@ -318,7 +286,6 @@ on: flag any }{ { - // Gitea >= 1.27 resolves the inputs server-side and sends native JSON types name: "native bool true", event: map[string]any{"inputs": map[string]any{"flag": true}}, flag: true, @@ -345,10 +312,10 @@ on: }, } - for eventName, workflow := range workflows { + for _, eventName := range []string{"workflow_call", "workflow_dispatch"} { for _, table := range tables { t.Run(eventName+"/"+table.name, func(t *testing.T) { - wf, err := model.ReadWorkflow(strings.NewReader(workflow)) + wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName))) require.NoError(t, err) rc := &RunContext{ @@ -366,7 +333,7 @@ on: } t.Run("deferred workflow call inputs", func(t *testing.T) { - workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + ` + workflow, err := model.ReadWorkflow(strings.NewReader(workflow("workflow_call") + ` jobs: test: {} call: @@ -406,7 +373,6 @@ on: config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}} ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}} - // the event's own inputs must not override those Gitea resolved job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}} assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc)) @@ -498,8 +464,8 @@ jobs: } a, b := containerName("a"), containerName("b") - assert.NotContains(t, a, "s3cr3t") // it reaches the container name, which no log masker covers - assert.NotEqual(t, a, b) // masking the name must not collapse two jobs onto one container + assert.NotContains(t, a, "s3cr3t") + assert.NotEqual(t, a, b) } func TestNewExpressionEvaluatorReadsJobOutputsWhileCombinationsWriteThem(t *testing.T) { diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index 6a3c63d7..bb65d01b 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -7,6 +7,7 @@ package runner import ( "archive/tar" "bytes" + "cmp" "context" "encoding/base64" "encoding/json/v2" @@ -155,10 +156,7 @@ func (e setupError) Unwrap() error { return e.error } func printCompleteJobName(rc *RunContext) common.Executor { return func(ctx context.Context) error { // Name holds a matrix combination; JobName is the shared name GitHub reports. - name := rc.JobName - if name == "" { - name = rc.Name - } + name := cmp.Or(rc.JobName, rc.Name) if name == "" && rc.Run != nil { name = rc.Run.JobID } @@ -358,10 +356,7 @@ func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common. return preErr // as on GitHub, no step runs after the job setup failed } mainErr := mainExecutor(ctx) - if preErr != nil { - return preErr - } - return mainErr + return cmp.Or(preErr, mainErr) } } @@ -547,8 +542,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) { ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout) defer cancel() env := rc.GetEnv() - caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"]) - if !hasJobSummaryCapability(caps) { + if !hasJobSummaryCapability(env["GITEA_ACTIONS_CAPABILITIES"]) { // Server did not advertise support. Do not attempt upload. return } @@ -681,19 +675,18 @@ func readSingleFileFromContainerArchive(ctx context.Context, env container.Execu } defer rc.Close() + requestedPath := path.Clean(strings.TrimPrefix(p, "/")) tr := tar.NewReader(rc) for { header, err := tr.Next() - if err == io.EOF { - return nil, false - } if err != nil { return nil, false } if header.Typeflag != tar.TypeReg { continue } - if !archiveEntryMatchesPath(header.Name, p) { + entryName := path.Clean(strings.TrimPrefix(header.Name, "/")) + if entryName != requestedPath && entryName != path.Base(requestedPath) { continue } // Summaries larger than the limit are truncated rather than dropped, so the @@ -716,12 +709,6 @@ func readSingleFileFromContainerArchive(ctx context.Context, env container.Execu } } -func archiveEntryMatchesPath(entryName, requestedPath string) bool { - entryName = path.Clean(strings.TrimPrefix(entryName, "/")) - requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/")) - return entryName == requestedPath || entryName == path.Base(requestedPath) -} - // useJobStageLogger tags the log entries of a job level executor with the stage they belong to. func useJobStageLogger(stage string, executor common.Executor) common.Executor { return func(ctx context.Context) error { diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index 987538e8..c3211dd5 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -364,6 +364,7 @@ func TestNewJobExecutor(t *testing.T) { output string startError error cancelOnStart bool + timeout bool }{ { name: "zeroSteps", @@ -514,17 +515,28 @@ func TestNewJobExecutor(t *testing.T) { result: "cancelled", cancelOnStart: true, }, - } - - contains := func(needle string, haystack []string) bool { - return slices.Contains(haystack, needle) + { + name: "post steps run after timeout", + steps: []*model.Step{{ID: "1"}}, + preSteps: []bool{false}, + postSteps: []bool{true}, + executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"}, + result: "failure", + timeout: true, + }, } for _, tt := range table { t.Run(tt.name, func(t *testing.T) { - fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act - - ctx, cancel := context.WithCancel(common.WithJobErrorContainer(t.Context())) + ctx := common.WithJobErrorContainer(t.Context()) + cancel := func() {} + var expire func() + if tt.timeout { + deadline := newControllableDeadlineContext(ctx) + ctx, expire = deadline, deadline.expire + } else { + ctx, cancel = context.WithCancel(ctx) + } defer cancel() jim := &jobInfoMock{} sfm := &stepFactoryMock{} @@ -563,7 +575,7 @@ func TestNewJobExecutor(t *testing.T) { sfm.On("newStep", stepModel, rc).Return(sm, nil) - sm.On("pre").Return(func(ctx context.Context) error { + sm.On("pre").Return(func(_ctx context.Context) error { if tt.preSteps[i] { executorOrder = append(executorOrder, "pre"+stepModel.ID) } @@ -572,6 +584,10 @@ func TestNewJobExecutor(t *testing.T) { sm.On("main").Return(func(ctx context.Context) error { executorOrder = append(executorOrder, "step"+stepModel.ID) + if tt.timeout { + expire() + return ctx.Err() + } if tt.hasError { return errors.New("error") } @@ -582,6 +598,9 @@ func TestNewJobExecutor(t *testing.T) { if tt.postSteps[i] { executorOrder = append(executorOrder, "post"+stepModel.ID) } + if tt.timeout { + require.NoError(t, ctx.Err(), "post step must run against a fresh, non-expired context") + } return nil }) @@ -599,7 +618,7 @@ func TestNewJobExecutor(t *testing.T) { return nil }) - if contains("stopContainer", tt.executedSteps) { + if slices.Contains(tt.executedSteps, "stopContainer") { jim.On("stopContainer").Return(func(ctx context.Context) error { executorOrder = append(executorOrder, "stopContainer") require.NoError(t, ctx.Err()) @@ -613,7 +632,7 @@ func TestNewJobExecutor(t *testing.T) { } 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") return nil }) @@ -634,8 +653,6 @@ func TestNewJobExecutor(t *testing.T) { jim.AssertExpectations(t) sfm.AssertExpectations(t) - - fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act }) } } @@ -666,123 +683,31 @@ func (ctx *controllableDeadlineContext) expire() { close(ctx.done) } -// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup -// path: when a job exceeds its timeout the job context is DeadlineExceeded, but -// the post steps (cleanup hooks like actions/checkout post and cache save) must -// still run against a fresh, non-expired context, and the job must still be -// reported as failed. -func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) { - ctx := newControllableDeadlineContext(common.WithJobErrorContainer(t.Context())) - - jim := &jobInfoMock{} - sfm := &stepFactoryMock{} - rc := &RunContext{ - JobContainer: &jobContainerMock{}, - Run: &model.Run{ - JobID: "test", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "test": {}, - }, - }, - }, - Config: &Config{}, - } - rc.ExprEval = rc.NewExpressionEvaluator(ctx) - - stepModel := &model.Step{ID: "1"} - jim.On("steps").Return([]*model.Step{stepModel}) - jim.On("matrix").Return(map[string]any{}) - jim.On("startContainer").Return(func(ctx context.Context) error { return nil }) - jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil }) - jim.On("closeContainer").Return(func(ctx context.Context) error { return nil }) - // The job timed out, so it must be reported as failed and still cleaned up. - jim.On("stopContainer").Return(func(context.Context) error { return nil }) - jim.On("result", "failure") - - sm := &stepMock{} - sfm.On("newStep", stepModel, rc).Return(sm, nil) - sm.On("pre").Return(func(ctx context.Context) error { return nil }) - sm.On("main").Return(func(stepCtx context.Context) error { - ctx.expire() - return stepCtx.Err() - }) - - var postRan bool - var postCtxErr error - sm.On("post").Return(func(ctx context.Context) error { - postRan = true - postCtxErr = ctx.Err() - return nil - }) - - executor := newJobExecutor(jim, sfm, rc) - // The executor itself returns nil on timeout: the failure is surfaced through - // the job result ("failure", asserted via the result mock below), not the - // return value. - require.NoError(t, executor(ctx)) - - assert.True(t, postRan, "post step must run after a job timeout") - require.NoError(t, postCtxErr, "post step must run against a fresh, non-expired context") - - jim.AssertExpectations(t) - sfm.AssertExpectations(t) - sm.AssertExpectations(t) -} - -// TestSetJobResultMatrixContinueOnError exercises the parallel-matrix path -// end-to-end: two combinations share one *model.Job and continue-on-error is -// keyed on matrix.experimental, so one combination tolerates its failure and the -// other does not. The job is reported as continue-on-error only when EVERY failing -// combination was tolerated; a single firm failure makes the whole job firm, and -// handleFailure then fails the run. func TestSetJobResultMatrixContinueOnError(t *testing.T) { - const jobYAML = "continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest" - - newSharedJob := func(t *testing.T) (*model.Job, *model.Workflow) { - t.Helper() - var job *model.Job - require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job)) - return job, &model.Workflow{ - Name: "workflow1", - Jobs: map[string]*model.Job{"job1": job}, - } - } - - planFor := func(wf *model.Workflow) *model.Plan { - return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}} - } - ctx := t.Context() + for name, tolerated := range map[string]bool{ + "one tolerated and one firm failure fails the run": false, + "all tolerated failures do not fail the run": true, + } { + t.Run(name, func(t *testing.T) { + var job *model.Job + require.NoError(t, yaml.Unmarshal([]byte("continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"), &job)) + workflow := &model.Workflow{Name: "workflow1", Jobs: map[string]*model.Job{"job1": job}} + for _, experimental := range []bool{true, tolerated} { + rc := newTestRC(workflow, map[string]any{"experimental": experimental}) + setJobResult(ctx, rc, rc, false) + } - // fail drives a single matrix combination through the failure path; each - // RunContext is its own jobInfo (rc implements jobInfo) and shares the job. - fail := func(wf *model.Workflow, experimental bool) { - rc := newTestRC(wf, map[string]any{"experimental": experimental}) - setJobResult(ctx, rc, rc, false) + assert.Equal(t, "failure", job.Result) + assert.Equal(t, tolerated, job.ContinueOnError) + err := handleFailure(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "job1"}}}}})(ctx) + if tolerated { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) } - - t.Run("one tolerated and one firm failure fails the run", func(t *testing.T) { - job, wf := newSharedJob(t) - // Order is intentional: the tolerated combination finishes first, then the - // firm one. The firm-failure latch must still win regardless of order. - fail(wf, true) - fail(wf, false) - - assert.Equal(t, "failure", job.Result) - assert.False(t, job.ContinueOnError, "a single firm failure must make the whole job firm") - assert.Error(t, handleFailure(planFor(wf))(ctx)) - }) - - t.Run("all tolerated failures do not fail the run", func(t *testing.T) { - job, wf := newSharedJob(t) - fail(wf, true) - fail(wf, true) - - assert.Equal(t, "failure", job.Result) - assert.True(t, job.ContinueOnError, "every failing combination was tolerated") - assert.NoError(t, handleFailure(planFor(wf))(ctx)) - }) } func TestHasJobSummaryCapability(t *testing.T) { @@ -969,44 +894,34 @@ func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t cm.AssertExpectations(t) } -func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() +func TestTryUploadJobSummarySkipsUnsupportedUploads(t *testing.T) { + for name, tt := range map[string]struct { + capabilities string + runtimeToken string + }{ + "requires exact capability": {"not-job-summary,job-summary-v2", fakeRuntimeToken(34)}, + "requires job ID": {"job-summary", "not-a-jwt"}, + } { + t.Run(name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _r *http.Request) { + requests++ + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() - rc := newJobSummaryRC(map[string]string{ - "GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2", - "ACTIONS_RUNTIME_URL": server.URL, - "ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34), - "GITEA_RUN_ID": "12", - }, &containerMock{}, 1) + rc := newJobSummaryRC(map[string]string{ + "GITEA_ACTIONS_CAPABILITIES": tt.capabilities, + "ACTIONS_RUNTIME_URL": server.URL, + "ACTIONS_RUNTIME_TOKEN": tt.runtimeToken, + "GITEA_RUN_ID": "12", + }, &containerMock{}, 1) - tryUploadJobSummary(t.Context(), rc) + tryUploadJobSummary(t.Context(), rc) - assert.Equal(t, 0, requests) -} - -func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - rc := newJobSummaryRC(map[string]string{ - "GITEA_ACTIONS_CAPABILITIES": "job-summary", - "ACTIONS_RUNTIME_URL": server.URL, - "ACTIONS_RUNTIME_TOKEN": "not-a-jwt", - "GITEA_RUN_ID": "12", - }, &containerMock{}, 1) - - tryUploadJobSummary(t.Context(), rc) - - assert.Equal(t, 0, requests) + assert.Zero(t, requests) + }) + } } func TestExtractJobIDFromRuntimeToken(t *testing.T) { @@ -1035,47 +950,39 @@ func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T cm.AssertExpectations(t) } -func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) { - logger, hook := logtest.NewLogger() - ctx := common.WithLogger(t.Context(), logger) - cm := &containerMock{} - content := strings.Repeat("a", 300) - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return( - io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))), - nil, - ).Once() +func TestReadSingleFileFromContainerArchiveSizeLimit(t *testing.T) { + for name, tt := range map[string]struct { + content string + maxBytes int64 + want string + }{ + "truncates oversized summary": {strings.Repeat("a", 300), 200, strings.Repeat("a", 200-len(jobSummaryTruncationMarker)) + jobSummaryTruncationMarker}, + "keeps exact limit": {"abc", 3, "abc"}, + } { + t.Run(name, func(t *testing.T) { + logger, hook := logtest.NewLogger() + ctx := common.WithLogger(t.Context(), logger) + cm := &containerMock{} + cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return( + io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: tt.content}))), + nil, + ).Once() - const maxBytes = 200 - body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes) + body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", tt.maxBytes) - // Oversized summaries are truncated to the limit (reserving room for the marker) - // rather than dropped entirely, and the truncation marker is appended. - assert.True(t, ok) - assert.LessOrEqual(t, len(body), maxBytes) - keep := maxBytes - len(jobSummaryTruncationMarker) - assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body) - if assert.Len(t, hook.AllRecords(), 1) { - assert.Contains(t, hook.AllRecords()[0].Message, "job summary truncated") + assert.True(t, ok) + assert.Equal(t, []byte(tt.want), body) + assert.LessOrEqual(t, int64(len(body)), tt.maxBytes) + if int64(len(tt.content)) > tt.maxBytes { + if assert.Len(t, hook.AllRecords(), 1) { + assert.Contains(t, hook.LastRecord().Message, "job summary truncated") + } + } else { + assert.Empty(t, hook.AllRecords()) + } + cm.AssertExpectations(t) + }) } - cm.AssertExpectations(t) -} - -func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) { - logger, hook := logtest.NewLogger() - ctx := common.WithLogger(t.Context(), logger) - cm := &containerMock{} - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return( - io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))), - nil, - ).Once() - - body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3) - - // A summary that is exactly at the limit is kept whole and not flagged as truncated. - assert.True(t, ok) - assert.Equal(t, []byte("abc"), body) - assert.Empty(t, hook.AllRecords()) - cm.AssertExpectations(t) } type tarEntry struct { @@ -1181,34 +1088,24 @@ func TestEvaluateJobContinueOnError(t *testing.T) { } func TestJobSetContinueOnError(t *testing.T) { - t.Run("first call true", func(t *testing.T) { - j := &model.Job{} - j.SetContinueOnError(true) - assert.True(t, j.ContinueOnError) - }) - t.Run("first call false", func(t *testing.T) { - j := &model.Job{} - j.SetContinueOnError(false) - assert.False(t, j.ContinueOnError) - }) - t.Run("true then false locks to false", func(t *testing.T) { - j := &model.Job{} - j.SetContinueOnError(true) - j.SetContinueOnError(false) - assert.False(t, j.ContinueOnError) - }) - t.Run("false then true stays false", func(t *testing.T) { - j := &model.Job{} - j.SetContinueOnError(false) - j.SetContinueOnError(true) - assert.False(t, j.ContinueOnError) - }) - t.Run("true then true stays true", func(t *testing.T) { - j := &model.Job{} - j.SetContinueOnError(true) - j.SetContinueOnError(true) - assert.True(t, j.ContinueOnError) - }) + for name, tt := range map[string]struct { + values []bool + want bool + }{ + "first call true": {[]bool{true}, true}, + "first call false": {[]bool{false}, false}, + "true then false locks to false": {[]bool{true, false}, false}, + "false then true stays false": {[]bool{false, true}, false}, + "true then true stays true": {[]bool{true, true}, true}, + } { + t.Run(name, func(t *testing.T) { + job := &model.Job{} + for _, value := range tt.values { + job.SetContinueOnError(value) + } + assert.Equal(t, tt.want, job.ContinueOnError) + }) + } } func TestTryUploadJobSummaryMasksSecrets(t *testing.T) { diff --git a/act/runner/logger.go b/act/runner/logger.go index a06c2efe..febe70de 100644 --- a/act/runner/logger.go +++ b/act/runner/logger.go @@ -51,29 +51,19 @@ const ( ) var ( - colors []int + colors = []int{blue, yellow, green, magenta, red, gray, cyan} nextColor int mux sync.Mutex ) -func init() { - nextColor = 0 - colors = []int{ - blue, yellow, green, magenta, red, gray, cyan, - } -} - type masksContextKey string const masksContextKeyVal = masksContextKey("logrus.FieldLogger") // Logger returns the appropriate logger for current context func Masks(ctx context.Context) *[]string { - val := ctx.Value(masksContextKeyVal) - if val != nil { - if masks, ok := val.(*[]string); ok { - return masks - } + if masks, ok := ctx.Value(masksContextKeyVal).(*[]string); ok { + return masks } return &[]string{} } @@ -178,7 +168,7 @@ var secretValueEncoders = []func(string) string{ uriDataEscape, url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+" url.PathEscape, - xmlDataEscape, + xmlDataEscaper.Replace, trimDoubleQuotes, } @@ -225,10 +215,6 @@ var xmlDataEscaper = strings.NewReplacer( "'", "'", ) -func xmlDataEscape(v string) string { - return xmlDataEscaper.Replace(v) -} - func trimDoubleQuotes(v string) string { if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) { return v[1 : len(v)-1] @@ -407,7 +393,7 @@ func (f textFormatter) format(r logging.Record) ([]byte, error) { b := &bytes.Buffer{} // the web renderer decodes command data, so this local view has to as well - if _, _, _, _, ok := parseActionCommand(r.Message, ""); ok && (strings.HasPrefix(r.Message, "::") || strings.HasPrefix(r.Message, "##[")) { + if _, _, _, ok := parseActionCommand(r.Message, ""); ok && (strings.HasPrefix(r.Message, "::") || strings.HasPrefix(r.Message, "##[")) { r.Message = UnescapeCommandData(r.Message) } r.Message = strings.TrimSuffix(r.Message, "\n") @@ -468,7 +454,8 @@ func (f textFormatter) print(b *bytes.Buffer, r logging.Record) { } func (f textFormatter) isColored() bool { - isColored := checkIfTerminal(f.w) + file, ok := f.w.(*os.File) + isColored := ok && term.IsTerminal(int(file.Fd())) if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" { isColored = true @@ -481,15 +468,6 @@ func (f textFormatter) isColored() bool { return isColored } -func checkIfTerminal(w io.Writer) bool { - switch v := w.(type) { - case *os.File: - return term.IsTerminal(int(v.Fd())) - default: - return false - } -} - // maskSecrets hides this job's secrets in a value that reaches somewhere the log maskers cannot, // such as a container name or a job summary. Masks added at runtime count, so a summary written // after ::add-mask:: is covered too. diff --git a/act/runner/max_parallel_test.go b/act/runner/max_parallel_test.go index 821fd802..8bc0e244 100644 --- a/act/runner/max_parallel_test.go +++ b/act/runner/max_parallel_test.go @@ -14,59 +14,25 @@ import ( ) func TestMaxParallelStrategy(t *testing.T) { - tests := []struct { + for _, tt := range []struct { name string maxParallelString string expectedMaxParallel int }{ - { - name: "max-parallel-1", - maxParallelString: "1", - expectedMaxParallel: 1, - }, - { - name: "max-parallel-2", - maxParallelString: "2", - expectedMaxParallel: 2, - }, - { - name: "max-parallel-default", - maxParallelString: "", - expectedMaxParallel: 4, - }, - { - name: "max-parallel-10-clamped-to-combinations", - maxParallelString: "10", - expectedMaxParallel: 5, - }, - { - name: "max-parallel-invalid-falls-back-to-default", - maxParallelString: "tow", - expectedMaxParallel: 4, - }, - } - - for _, tt := range tests { + {"max-parallel-1", "1", 1}, + {"max-parallel-2", "2", 2}, + {"max-parallel-default", "", 4}, + {"max-parallel-10-clamped-to-combinations", "10", 5}, + {"max-parallel-invalid-falls-back-to-default", "tow", 4}, + } { t.Run(tt.name, func(t *testing.T) { - matrix := map[string][]any{ - "version": {1, 2, 3, 4, 5}, - } - var rawMatrix yaml.Node - err := rawMatrix.Encode(matrix) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - job := &model.Job{ - Strategy: &model.Strategy{ - MaxParallelString: tt.maxParallelString, - RawMatrix: rawMatrix, - }, - } + require.NoError(t, rawMatrix.Encode(map[string][]any{"version": {1, 2, 3, 4, 5}})) + job := &model.Job{Strategy: &model.Strategy{MaxParallelString: tt.maxParallelString, RawMatrix: rawMatrix}} matrixes, err := job.GetMatrixes() - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - assert.NotNil(t, matrixes) - assert.Len(t, matrixes, 5) + require.NoError(t, err) + require.Len(t, matrixes, 5) assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes))) }) } diff --git a/act/runner/patch_actions_e2e_test.go b/act/runner/patch_actions_e2e_test.go index c9ca6c6e..d4ebd22d 100644 --- a/act/runner/patch_actions_e2e_test.go +++ b/act/runner/patch_actions_e2e_test.go @@ -25,12 +25,8 @@ import ( "github.com/stretchr/testify/require" ) -// actionsCacheRef pins the actions/cache release this is verified against. Bump it -// deliberately: a new release is exactly what can stop the patch matching. const actionsCacheRef = "v6.1.0" -// bundleFromGitHub downloads one bundle file, keeping it in the user cache dir so repeated runs -// cost nothing. The bundles are megabytes, too large to vendor. func bundleFromGitHub(t *testing.T, repo, ref, path string) string { t.Helper() @@ -65,17 +61,28 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string { return bundle } -// jobEnv is the environment a job gets from this runner, which is what decides where an action's -// toolkit looks for the cache and artifact services. type jobEnv struct { workspace, runnerTemp string cacheURL, resultsURL string token string } -// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests -// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose -// names are the ones the action's own action.yml uses. +func newActionJobEnv(t *testing.T, token string, credential artifactcache.JobCredential) jobEnv { + t.Helper() + + handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) + require.NoError(t, err) + t.Cleanup(func() { _ = handler.Close() }) + t.Cleanup(handler.RegisterJob(token, credential)) + return jobEnv{ + workspace: tempDirPath(t), + runnerTemp: tempDirPath(t), + cacheURL: handler.ExternalURL(), + resultsURL: handler.ExternalURL(), + token: token, + } +} + func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string { t.Helper() state := filepath.Join(env.runnerTemp, "state") @@ -110,7 +117,6 @@ func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[str return string(out) } -// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be. func patchedAction(t *testing.T, repo, ref, entrypoint string) string { t.Helper() @@ -122,8 +128,6 @@ func patchedAction(t *testing.T, repo, ref, entrypoint string) string { return script } -// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve -// to /private/var and the client derives archive paths relative to the workspace. func tempDirPath(t *testing.T) string { t.Helper() @@ -132,32 +136,13 @@ func tempDirPath(t *testing.T) string { return dir } -// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the -// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it -// save and restore through this runner's cache server. The unreachable results URL is the point, -// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release -// stops matching the patch the client falls back to v1 and this fails on the version line, which -// is the signal to look at the new bundle. func TestCacheServiceV2EndToEnd(t *testing.T) { requireHostTools(t, "node") restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js") save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js") - handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) - require.NoError(t, err) - t.Cleanup(func() { _ = handler.Close() }) - const token, repo = "e2e-runtime-token", "testuser/testrepo" - handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo}) - - env := jobEnv{ - workspace: tempDirPath(t), - runnerTemp: tempDirPath(t), - cacheURL: handler.ExternalURL(), - // The results service is the cache server's too, which is what the runner advertises. - resultsURL: handler.ExternalURL(), - token: token, - } + env := newActionJobEnv(t, "e2e-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}) require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755)) content := []byte("cached through the patched gate") require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600)) @@ -179,28 +164,17 @@ func TestCacheServiceV2EndToEnd(t *testing.T) { got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt")) require.NoError(t, err) assert.Equal(t, content, got) - - // Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the - // cache server on its own address. That is what a runner without a results service of its own - // leaves its jobs with, so it has to round trip too. env.workspace = tempDirPath(t) v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs) require.Contains(t, v1, "Cache service version: v1") require.Contains(t, v1, "Cache restored from key: "+key) } -// The gate and the URL getter are separate functions, and a bundler may put either first: the gap -// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is -// anchored on that distance. One bundle file from each of the families that bundle the cache -// toolkit, patched but not run, is what keeps a future release from quietly matching only one of -// the two shapes and leaving every cache on v1. func TestPatchedBundleAcrossActions(t *testing.T) { for _, tc := range []struct { repo, ref, path string wantPatched bool }{ - // The cache toolkit, in each bundler shape and from a spread of ecosystems, including the - // actions that drive a Go or a Rust cache client of their own. {"actions/cache", actionsCacheRef, "dist/restore/index.js", true}, {"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true}, {"actions/setup-python", "v7.0.0", "dist/setup/index.js", true}, @@ -214,14 +188,9 @@ func TestPatchedBundleAcrossActions(t *testing.T) { {"Swatinem/rust-cache", "v2.9.2", "dist/cache-CTfQoCTL.js", true}, {"docker/build-push-action", "v7.4.0", "dist/index.cjs", true}, {"gradle/actions", "v6.4.0", "dist/setup-gradle/main/index.js", true}, - // The artifact toolkit, where the gate is a refusal and there is nothing to redirect. - // v4.4.0 is the first release whose gate carries the localhost test this matches; the - // releases before it refuse in a shape the runner leaves alone. {"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true}, {"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true}, {"actions/download-artifact", "v8.0.2", "dist/index.js", true}, - // Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the - // one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs. {"actions/checkout", "v7.0.1", "dist/index.js", false}, {"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false}, } { @@ -240,10 +209,8 @@ func TestPatchedBundleAcrossActions(t *testing.T) { assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed") if !strings.Contains(string(data), CacheServiceV2Env) { - return // the artifact toolkit: a refusal to open, and no URL to move + return } - // Only the reads inside getCacheServiceURL are rewritten. The others, such as the - // feature-availability check, must be left as they are. assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"), "the cache service URL was not redirected") assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv), @@ -252,10 +219,6 @@ func TestPatchedBundleAcrossActions(t *testing.T) { } } -// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk -// to the results service, which is this runner's cache server forwarding the artifact half on to -// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once: -// the patch, and the forwarding the job's registration set up. func TestUploadArtifactThroughTheResultsService(t *testing.T) { requireHostTools(t, "node") @@ -280,12 +243,12 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) { case "download": w.Header().Set("Content-Type", "application/zip") _, _ = w.Write(zipped) - default: // the zip on its way up, in the blocks the Azure protocol puts it in + default: body, _ := io.ReadAll(r.Body) switch r.URL.Query().Get("comp") { case "block": zipped = append(zipped, body...) - case "blocklist": // the ordering document, not content + case "blocklist": default: zipped = body } @@ -293,24 +256,11 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) { } })) defer gitea.Close() - - handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) - require.NoError(t, err) - t.Cleanup(func() { _ = handler.Close() }) - // The artifact client decodes the runtime token for the run ids it puts in its requests, where - // the cache client only presents it, so this one has to be shaped like Gitea's. - token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig" - defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})() + env := newActionJobEnv(t, "e30."+base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`))+".sig", + artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL}) upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js") - env := jobEnv{ - workspace: tempDirPath(t), - runnerTemp: tempDirPath(t), - cacheURL: handler.ExternalURL(), - resultsURL: handler.ExternalURL(), - token: token, - } uploaded := []byte("through the results service") require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600)) @@ -321,9 +271,6 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) { }) require.Contains(t, out, "has been successfully uploaded") - - // And back down again: listing and downloading go the same way, and the signed URL the - // artifact service hands out is fetched straight from it. download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js") env.workspace = tempDirPath(t) out = runActionEntrypoint(t, download, env, map[string]string{ @@ -340,27 +287,12 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) { assert.Equal(t, uploaded, got) } -// The setup actions carry the same toolkit and reach the same service, from a key of their own -// making. setup-node is the cheapest of them to run: given a lockfile and no version to install, -// it does the cache lookup and nothing else. func TestSetupActionFindsTheCacheService(t *testing.T) { requireHostTools(t, "node", "npm") setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js") - handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) - require.NoError(t, err) - t.Cleanup(func() { _ = handler.Close() }) - const token = "setup-runtime-token" - defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})() - - env := jobEnv{ - workspace: tempDirPath(t), - runnerTemp: tempDirPath(t), - cacheURL: handler.ExternalURL(), - resultsURL: handler.ExternalURL(), - token: token, - } + env := newActionJobEnv(t, "setup-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}) require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0o600)) @@ -395,20 +327,9 @@ func TestCodeSplitActionFindsTheCacheService(t *testing.T) { } t.Setenv("CARGO_HOME", tempDirPath(t)) - handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) - require.NoError(t, err) - t.Cleanup(func() { _ = handler.Close() }) - const token = "split-runtime-token" - defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})() - defer patchActions(t.Context(), actionScriptPaths(dir, dir, action))() - out := runActionEntrypoint(t, filepath.Join(dir, action.Runs.Main), jobEnv{ - workspace: tempDirPath(t), - runnerTemp: tempDirPath(t), - cacheURL: handler.ExternalURL(), - resultsURL: handler.ExternalURL(), - token: token, - }, inputs) + out := runActionEntrypoint(t, filepath.Join(dir, action.Runs.Main), + newActionJobEnv(t, "split-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}), inputs) require.Contains(t, out, "Cache service version: v2") require.Contains(t, out, "No cache found.", "the lookup did not reach the cache server") diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index b115343c..9a0c04a4 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -107,7 +107,13 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cac return err } - runner, err := newReusableWorkflowRunner(rc) + runner, err := (&runnerImpl{ + config: rc.Config, + caller: &caller{ + runContext: rc, + reusedWorkflowJobResults: map[string]string{}, + }, + }).configure() if err != nil { return err } @@ -119,20 +125,6 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cac } } -func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) { - runner := &runnerImpl{ - config: rc.Config, - eventJSON: rc.EventJSON, - caller: &caller{ - runContext: rc, - - reusedWorkflowJobResults: map[string]string{}, // For Gitea - }, - } - - return runner.configure() -} - type remoteReusableWorkflow struct { *model.ReusableWorkflowUses URL string @@ -163,38 +155,32 @@ func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, e // For Gitea func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor { return func(ctx context.Context) error { - caller := runner.caller - - allJobDone := true hasFailure := false - for _, result := range caller.reusedWorkflowJobResults { + for _, result := range runner.caller.reusedWorkflowJobResults { if result == "pending" { - allJobDone = false - break + return nil } if result == "failure" { hasFailure = true } } - if allJobDone { - reusedWorkflowJobResult := "success" - reusedWorkflowJobResultMessage := "succeeded" - if hasFailure { - reusedWorkflowJobResult = "failure" - reusedWorkflowJobResultMessage = "failed" - } + reusedWorkflowJobResult := "success" + reusedWorkflowJobResultMessage := "succeeded" + if hasFailure { + reusedWorkflowJobResult = "failure" + reusedWorkflowJobResultMessage = "failed" + } - if rc.caller != nil { - rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult) - } else { - // Serialize this shared Job.Result write against the other matrix combos - // and setJobResult (same lockJob key). - unlock := lockJob(rc.Run.Job()) - rc.result(reusedWorkflowJobResult) - unlock() - common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage) - } + if rc.caller != nil { + rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult) + } else { + // Serialize this shared Job.Result write against the other matrix combos + // and setJobResult (same lockJob key). + unlock := lockJob(rc.Run.Job()) + rc.result(reusedWorkflowJobResult) + unlock() + common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage) } return nil diff --git a/act/runner/run_context.go b/act/runner/run_context.go index a920d688..a9f8179b 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -229,8 +229,7 @@ func (rc *RunContext) networkNameForGitea() (string, bool) { } func getDockerDaemonSocketMountPath(daemonPath string) string { - if before, after, ok := strings.Cut(daemonPath, "://"); ok { - scheme := before + if scheme, after, ok := strings.Cut(daemonPath, "://"); ok { switch { case strings.EqualFold(scheme, "npipe"): // linux container mount on windows, use the default socket path of the VM / wsl2 @@ -251,10 +250,7 @@ func getDockerDaemonSocketMountPath(daemonPath string) string { // without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job // must never write to it. func (rc *RunContext) containerDaemonSocket() string { - if rc.Config.ContainerDaemonSocket == "" { - return "/var/run/docker.sock" - } - return rc.Config.ContainerDaemonSocket + return cmp.Or(rc.Config.ContainerDaemonSocket, "/var/run/docker.sock") } const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared @@ -304,10 +300,7 @@ func (rc *RunContext) startDockerProxy(ctx context.Context) { // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. func (rc *RunContext) toolCache(fallback string) string { - if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" { - return path - } - return fallback + return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback) } // runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache @@ -551,10 +544,7 @@ func (rc *RunContext) startJobContainer() common.Executor { rc.Env["JOB_CONTAINER_NAME"] = name delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name - envList := make([]string, 0) - - envList = append(envList, rc.runnerEnv(ctx)...) - envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions + envList := append(rc.runnerEnv(ctx), "LANG=C.UTF-8") // Use same locale as GitHub Actions // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network ) // if using service containers, will create a new network for the containers. @@ -571,12 +561,8 @@ func (rc *RunContext) startJobContainer() common.Executor { rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)}) } - // For Gitea, `jobContainerNetwork` should be the same as `networkName` - jobContainerNetwork := networkName - ext := container.LinuxContainerEnvironmentExtensions{} containerInput := &container.NewContainerInput{ - Cmd: nil, Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)}, WorkingDir: ext.ToContainerPath(rc.Config.Workdir), Image: image, @@ -584,7 +570,7 @@ func (rc *RunContext) startJobContainer() common.Executor { Password: password, Name: name, Env: envList, - NetworkMode: jobContainerNetwork, + NetworkMode: networkName, NetworkAliases: []string{rc.Name}, Stdout: logWriter, Stderr: logWriter, @@ -832,9 +818,7 @@ func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string cenv := map[string]string{} var cpath string if err := rc.JobContainer.UpdateFromImageEnv(&cenv)(ctx); err == nil { - if p, ok := cenv[path]; ok { - cpath = p - } + cpath = cenv[path] } if len(cpath) == 0 { cpath = rc.JobContainer.DefaultPathVariable() @@ -875,7 +859,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string) return nil } -func (rc *RunContext) stopJobContainer() common.Executor { +func (rc *RunContext) stopContainer() common.Executor { return func(ctx context.Context) error { if rc.cleanUpJobContainer != nil { return rc.cleanUpJobContainer(ctx) @@ -1118,9 +1102,8 @@ func (rc *RunContext) ActionCacheDir() string { if rc.Config.ActionCacheDir != "" { return rc.Config.ActionCacheDir } - var xdgCache string - var ok bool - if xdgCache, ok = os.LookupEnv("XDG_CACHE_HOME"); !ok || xdgCache == "" { + xdgCache := os.Getenv("XDG_CACHE_HOME") + if xdgCache == "" { if home, err := os.UserHomeDir(); err == nil { xdgCache = filepath.Join(home, ".cache") } else if xdgCache, err = filepath.Abs("."); err != nil { @@ -1192,10 +1175,6 @@ func (rc *RunContext) IsHostEnv() bool { return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted") } -func (rc *RunContext) stopContainer() common.Executor { - return rc.stopJobContainer() -} - func (rc *RunContext) closeContainer() common.Executor { return func(ctx context.Context) error { if rc.JobContainer != nil { @@ -1417,29 +1396,20 @@ func mergeMaps(maps ...map[string]string) map[string]string { return rtnMap } +var containerNamePattern = regexp.MustCompile("[^a-zA-Z0-9]") + func createContainerName(parts ...string) string { name := strings.Join(parts, "-") - pattern := regexp.MustCompile("[^a-zA-Z0-9]") - name = pattern.ReplaceAllString(name, "-") + name = containerNamePattern.ReplaceAllString(name, "-") name = strings.ReplaceAll(name, "--", "-") hash := sha256.Sum256([]byte(name)) // SHA256 is 64 hex characters. So trim name to 63 characters to make room for the hash and separator - trimmedName := strings.Trim(trimToLen(name, 63), "-") + trimmedName := strings.Trim(name[:min(len(name), 63)], "-") return fmt.Sprintf("%s-%x", trimmedName, hash) } -func trimToLen(s string, l int) string { - if l < 0 { - l = 0 - } - if len(s) > l { - return s[:l] - } - return s -} - // ownStatus is the result of this context's own steps, which for a composite action is its action status func (rc *RunContext) ownStatus() string { if rc.jobFailed { @@ -1484,10 +1454,6 @@ func (rc *RunContext) getJobContext() *model.JobContext { return jobContext } -func (rc *RunContext) getStepsContext() map[string]*model.StepResult { - return rc.StepResults -} - // getRunnerContext returns the `runner` context: what the execution environment knows // (os, arch, temp, tool_cache) plus what only the runner process knows. func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any { @@ -1521,9 +1487,9 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext ghc := &model.GithubContext{ Event: make(map[string]any), Workflow: rc.Run.Workflow.Name, - RunID: rc.Config.Env["GITHUB_RUN_ID"], - RunNumber: rc.Config.Env["GITHUB_RUN_NUMBER"], - Actor: rc.Config.Actor, + RunID: cmp.Or(rc.Config.Env["GITHUB_RUN_ID"], "1"), + RunNumber: cmp.Or(rc.Config.Env["GITHUB_RUN_NUMBER"], "1"), + Actor: cmp.Or(rc.Config.Actor, "nektos/act"), // for configs that require a default rather than being run as a cmd EventName: rc.Config.EventName, Action: rc.CurrentStep, Token: rc.Config.Token, @@ -1532,8 +1498,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext ActionRepository: rc.Env["GITHUB_ACTION_REPOSITORY"], ActionRef: rc.Env["GITHUB_ACTION_REF"], RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"], - RetentionDays: rc.Config.Env["GITHUB_RETENTION_DAYS"], - RunnerPerflog: rc.Config.Env["RUNNER_PERFLOG"], + RetentionDays: cmp.Or(rc.Config.Env["GITHUB_RETENTION_DAYS"], "0"), + RunnerPerflog: cmp.Or(rc.Config.Env["RUNNER_PERFLOG"], "/dev/null"), RunnerTrackingID: rc.Config.Env["RUNNER_TRACKING_ID"], Repository: rc.Config.Env["GITHUB_REPOSITORY"], Ref: rc.Config.Env["GITHUB_REF"], @@ -1548,40 +1514,16 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json" } - if ghc.RunID == "" { - ghc.RunID = "1" - } + if preset := rc.Config.PresetGitHubContext; preset != nil { + merged := *preset + merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath + merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef + merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID + *ghc = merged - if ghc.RunNumber == "" { - ghc.RunNumber = "1" - } - - if ghc.RetentionDays == "" { - ghc.RetentionDays = "0" - } - - if ghc.RunnerPerflog == "" { - ghc.RunnerPerflog = "/dev/null" - } - - // Backwards compatibility for configs that require - // a default rather than being run as a cmd - if ghc.Actor == "" { - ghc.Actor = "nektos/act" - } - - { // Adapt to Gitea - if preset := rc.Config.PresetGitHubContext; preset != nil { - merged := *preset - merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath - merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef - merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID - *ghc = merged - - rc.setInstanceURLs(ghc) - ghc.GraphQLURL = "" // Gitea doesn't support graphql - return ghc - } + rc.setInstanceURLs(ghc) + ghc.GraphQLURL = "" // Gitea doesn't support graphql + return ghc } if rc.EventJSON != "" { @@ -1634,10 +1576,6 @@ func withHTTPS(u string) string { } func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool { - if step.Type() == model.StepTypeInvalid { - // This will be errored out by the executor later, we need this here to avoid a null panic though - return false - } if step.Type() != model.StepTypeUsesActionRemote { return false } @@ -1702,7 +1640,8 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon } if rc.Config.ArtifactServerPath != "" { - setActionRuntimeVars(rc, env) + env["ACTIONS_RUNTIME_URL"] = cmp.Or(os.Getenv("ACTIONS_RUNTIME_URL"), "http://"+net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)+"/") + env["ACTIONS_RUNTIME_TOKEN"] = cmp.Or(os.Getenv("ACTIONS_RUNTIME_TOKEN"), "token") } if imageOS := rc.imageOS(ctx); imageOS != "" { @@ -1757,20 +1696,6 @@ func imageOSFromImage(image string) string { return "" } -func setActionRuntimeVars(rc *RunContext, env map[string]string) { - actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL") - if actionsRuntimeURL == "" { - actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/" - } - env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL - - actionsRuntimeToken := os.Getenv("ACTIONS_RUNTIME_TOKEN") - if actionsRuntimeToken == "" { - actionsRuntimeToken = "token" - } - env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken -} - func registryCredentials(credentials map[string]string, prefix string) (string, string, error) { if credentials == nil { return "", "", nil diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index e73a651c..7a293bc1 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -40,13 +40,6 @@ import ( ) func TestRunContext_EvalBool(t *testing.T) { - var yml yaml.Node - err := yml.Encode(map[string][]any{ - "os": {"Linux", "Windows"}, - "foo": {"bar", "baz"}, - }) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - rc := &RunContext{ Config: &Config{ Workdir: ".", @@ -61,26 +54,12 @@ func TestRunContext_EvalBool(t *testing.T) { Workflow: &model.Workflow{ Name: "test-workflow", Jobs: map[string]*model.Job{ - "job1": { - Strategy: &model.Strategy{ - RawMatrix: yml, - }, - }, + "job1": {}, }, }, }, - Matrix: map[string]any{ - "os": "Linux", - "foo": "bar", - }, StepResults: map[string]*model.StepResult{ - "id1": { - Conclusion: model.StepStatusSuccess, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{ - "foo": "bar", - }, - }, + "id1": {Conclusion: model.StepStatusSuccess, Outcome: model.StepStatusFailure}, }, } rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) @@ -90,21 +69,12 @@ func TestRunContext_EvalBool(t *testing.T) { out bool wantErr bool }{ - // The basic ones {in: "failure()", out: false}, {in: "success()", out: true}, {in: "cancelled()", out: false}, {in: "always()", out: true}, - // TODO: move to sc.NewExpressionEvaluator(), because "steps" context is not available here - // {in: "steps.id1.conclusion == 'success'", out: true}, - // {in: "steps.id1.conclusion != 'success'", out: false}, - // {in: "steps.id1.outcome == 'failure'", out: true}, - // {in: "steps.id1.outcome != 'failure'", out: false}, {in: "true", out: true}, {in: "false", out: false}, - // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not - // {in: "!true", wantErr: true}, - // {in: "!false", wantErr: true}, {in: "1 != 0", out: true}, {in: "1 != 1", out: false}, {in: "${{ 1 != 0 }}", out: true}, @@ -113,32 +83,24 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "1 == 1", out: true}, {in: "1 > 2", out: false}, {in: "1 < 2", out: true}, - // And or {in: "true && false", out: false}, {in: "true && 1 < 2", out: true}, {in: "false || 1 < 2", out: true}, {in: "false || false", out: false}, - // None boolable {in: "env.UNKNOWN == 'true'", out: false}, {in: "env.UNKNOWN", out: false}, - // Inline expressions {in: "env.SOME_TEXT", out: true}, {in: "env.SOME_TEXT == 'text'", out: true}, {in: "env.SOMETHING_TRUE == 'true'", out: true}, {in: "env.SOMETHING_FALSE == 'true'", out: false}, {in: "env.SOMETHING_TRUE", out: true}, {in: "env.SOMETHING_FALSE", out: true}, - // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not - // {in: "!env.SOMETHING_TRUE", wantErr: true}, - // {in: "!env.SOMETHING_FALSE", wantErr: true}, {in: "${{ !env.SOMETHING_TRUE }}", out: false}, {in: "${{ !env.SOMETHING_FALSE }}", out: false}, {in: "${{ ! env.SOMETHING_TRUE }}", out: false}, {in: "${{ ! env.SOMETHING_FALSE }}", out: false}, {in: "${{ env.SOMETHING_TRUE }}", out: true}, {in: "${{ env.SOMETHING_FALSE }}", out: true}, - {in: "${{ !env.SOMETHING_TRUE }}", out: false}, - {in: "${{ !env.SOMETHING_FALSE }}", out: false}, {in: "${{ !env.SOMETHING_TRUE && true }}", out: false}, {in: "${{ !env.SOMETHING_FALSE && true }}", out: false}, {in: "${{ !env.SOMETHING_TRUE || true }}", out: true}, @@ -146,14 +108,11 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "${{ env.SOMETHING_TRUE && true }}", out: true}, {in: "${{ env.SOMETHING_FALSE || true }}", out: true}, {in: "${{ env.SOMETHING_FALSE || false }}", out: true}, - // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not - // {in: "!env.SOMETHING_TRUE || true", wantErr: true}, {in: "${{ env.SOMETHING_TRUE == 'true'}}", out: true}, {in: "${{ env.SOMETHING_FALSE == 'true'}}", out: false}, {in: "${{ env.SOMETHING_FALSE == 'false'}}", out: true}, {in: "${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", out: true}, - // All together now {in: "false || env.SOMETHING_TRUE == 'true'", out: true}, {in: "true || env.SOMETHING_FALSE == 'true'", out: true}, {in: "true && env.SOMETHING_TRUE == 'true'", out: true}, @@ -162,33 +121,26 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "env.SOMETHING_FALSE == 'true' && true", out: false}, {in: "${{ env.SOMETHING_FALSE == 'true' }} && true", out: true}, {in: "true && ${{ env.SOMETHING_FALSE == 'true' }}", out: true}, - // Check github context {in: "github.actor == 'nektos/act'", out: true}, {in: "github.actor == 'unknown'", out: false}, {in: "github.job == 'job1'", out: true}, - // The special ACT flag {in: "${{ env.ACT }}", out: true}, {in: "${{ !env.ACT }}", out: false}, - // Invalid expressions should be reported {in: "INVALID_EXPRESSION", wantErr: true}, } for _, table := range tables { t.Run(table.in, func(t *testing.T) { - assertObject := assert.New(t) b, err := EvalBool(t.Context(), rc.ExprEval, table.in, exprparser.DefaultStatusCheckSuccess) if table.wantErr { - assertObject.Error(err) //nolint:testifylint // pre-existing issue from nektos/act + require.Error(t, err) } - assertObject.Equal(table.out, b, fmt.Sprintf("Expected %s to be %v, was %v", table.in, table.out, b)) //nolint:testifylint // pre-existing issue from nektos/act + assert.Equal(t, table.out, b) }) } } -// fakeContainer turns every container operation into a no-op, so startJobContainer -// runs without a Docker daemon. The embedded interface is nil, so any method the -// test does not exercise panics rather than silently doing the wrong thing. type fakeContainer struct { container.ExecutionsEnvironment } @@ -244,7 +196,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []* t.Cleanup(func() { newContainer = origNewContainer }) cfg.Workdir = "/tmp" - cfg.ContainerNetworkMode = "host" // an explicit network mode creates no network + cfg.ContainerNetworkMode = "host" cfg.Env = map[string]string{} cfg.Secrets = map[string]string{} @@ -287,8 +239,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []* return inputs } -// Regression test: a service without a `credentials:` block resolves to empty -// credentials, which used to overwrite the job container's own credentials. func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) { inputs := startJobContainerInputs(t, ` name: test @@ -317,9 +267,7 @@ jobs: credentials[in.Image] = [2]string{in.Username, in.Password} } - // the job container keeps its own credentials, whichever services exist require.Equal(t, [2]string{"job-user", "job-password"}, credentials["registry.example/private:latest"]) - // each service keeps its own, and a service without credentials gets none require.Equal(t, [2]string{"db-user", "db-password"}, credentials["postgres:latest"]) require.Equal(t, [2]string{"", ""}, credentials["redis:latest"]) } @@ -335,10 +283,10 @@ jobs: - data:/data `, &Config{ValidVolumes: []string{"data"}})[0] - require.Equal(t, "redis:latest", redis.Image) // services are built before the job container + require.Equal(t, "redis:latest", redis.Image) require.Equal(t, []string{"data"}, redis.ValidVolumes) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) - require.Empty(t, redis.Binds) // the docker socket is the job container's alone + require.Empty(t, redis.Binds) require.Empty(t, redis.WorkingDir) require.Nil(t, redis.Entrypoint) require.Nil(t, redis.Cmd) @@ -388,8 +336,6 @@ jobs: require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions) } -// Only the workflow's options may be stripped later, so the two sources have to reach the -// container apart from each other. func TestStartJobContainerKeepsRunnerOptionsApartFromWorkflowOptions(t *testing.T) { inputs := startJobContainerInputs(t, ` name: test @@ -413,12 +359,9 @@ jobs: } require.Equal(t, [2]string{"--device /dev/fuse", "--cap-add SYS_PTRACE"}, options["registry.example/job:latest"]) - // a service container gets no options from the runner's config today require.Equal(t, [2]string{"", "--shm-size 1g"}, options["redis:latest"]) } -// A service container reaches the internet the same way the job does, so it inherits the -// job's proxy; a service that sets the variable itself keeps its own value. func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) { inputs := startJobContainerInputs(t, ` name: test @@ -442,14 +385,11 @@ jobs: require.Contains(t, redis.Env, "http_proxy=http://proxy:3128") require.Contains(t, redis.Env, "no_proxy=internal.example") - // the service's own env wins over what the runner injected, without dropping the rest require.Contains(t, db.Env, "no_proxy=db-only.example") require.NotContains(t, db.Env, "no_proxy=internal.example") require.Contains(t, db.Env, "http_proxy=http://proxy:3128") } -// act builds Dockerfile actions through the API, which does not pre-populate the proxy -// build args the docker CLI would, so the RUN steps would have no network behind a proxy. func TestProxyBuildArgs(t *testing.T) { rc := &RunContext{Config: &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128"}}} @@ -458,69 +398,56 @@ func TestProxyBuildArgs(t *testing.T) { require.Len(t, args, 1) require.Equal(t, "http://proxy:3128", *args["http_proxy"]) - // a job without a proxy builds exactly as it does today require.Nil(t, (&RunContext{Config: &Config{}}).proxyBuildArgs()) } func TestRunContext_GetBindsAndMounts(t *testing.T) { - rctemplate := &RunContext{ - Name: "TestRCName", - Run: &model.Run{ - Workflow: &model.Workflow{ - Name: "TestWorkflowName", + newRunContext := func(config *Config) *RunContext { + return &RunContext{ + Name: "TestRCName", + Run: &model.Run{ + Workflow: &model.Workflow{Name: "TestWorkflowName"}, }, - }, - Config: &Config{ - BindWorkdir: false, - }, + Config: config, + } } + rctemplate := newRunContext(&Config{}) tests := []struct { windowsPath bool - name string - rc *RunContext - wantbind string - wantmount string + name, want string }{ - {false, "/mnt/linux", rctemplate, "/mnt/linux", "/mnt/linux"}, - {false, "/mnt/path with spaces/linux", rctemplate, "/mnt/path with spaces/linux", "/mnt/path with spaces/linux"}, - {true, "C:\\Users\\TestPath\\MyTestPath", rctemplate, "/mnt/c/Users/TestPath/MyTestPath", "/mnt/c/Users/TestPath/MyTestPath"}, - {true, "C:\\Users\\Test Path with Spaces\\MyTestPath", rctemplate, "/mnt/c/Users/Test Path with Spaces/MyTestPath", "/mnt/c/Users/Test Path with Spaces/MyTestPath"}, - {true, "/LinuxPathOnWindowsShouldFail", rctemplate, "", ""}, + {false, "/mnt/linux", "/mnt/linux"}, + {false, "/mnt/path with spaces/linux", "/mnt/path with spaces/linux"}, + {true, `C:\Users\TestPath\MyTestPath`, "/mnt/c/Users/TestPath/MyTestPath"}, + {true, `C:\Users\Test Path with Spaces\MyTestPath`, "/mnt/c/Users/Test Path with Spaces/MyTestPath"}, + {true, "/LinuxPathOnWindowsShouldFail", ""}, } - isWindows := runtime.GOOS == "windows" - for _, testcase := range tests { - // pin for scopelint + if (runtime.GOOS == "windows") != testcase.windowsPath { + continue + } for _, bindWorkDir := range []bool{true, false} { - // pin for scopelint testBindSuffix := "" if bindWorkDir { testBindSuffix = "Bind" } + t.Run(testcase.name+testBindSuffix, func(t *testing.T) { + rctemplate.Config.Workdir = testcase.name + rctemplate.Config.BindWorkdir = bindWorkDir + gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context()) - // Only run windows path tests on windows and non-windows on non-windows - if (isWindows && testcase.windowsPath) || (!isWindows && !testcase.windowsPath) { - t.Run((testcase.name + testBindSuffix), func(t *testing.T) { - config := testcase.rc.Config - config.Workdir = testcase.name - config.BindWorkdir = bindWorkDir - gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context()) - - // Name binds/mounts are either/or - if config.BindWorkdir { - fullBind := testcase.name + ":" + testcase.wantbind - if runtime.GOOS == "darwin" { - fullBind += ":delegated" - } - assert.Contains(t, gotbind, fullBind) - } else { - mountkey := testcase.rc.jobContainerName() - assert.EqualValues(t, testcase.wantmount, gotmount[mountkey]) //nolint:testifylint // pre-existing issue from nektos/act + if bindWorkDir { + fullBind := testcase.name + ":" + testcase.want + if runtime.GOOS == "darwin" { + fullBind += ":delegated" } - }) - } + assert.Contains(t, gotbind, fullBind) + } else { + assert.Equal(t, testcase.want, gotmount[rctemplate.jobContainerName()]) + } + }) } } @@ -548,21 +475,12 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { }) require.NoError(t, err) - rc := &RunContext{ - Name: "TestRCName", - Run: &model.Run{ - Workflow: &model.Workflow{ - Name: "TestWorkflowName", - }, + rc := newRunContext(&Config{ + ValidVolumes: []string{"**"}, + Secrets: map[string]string{ + "MAME": "/host/mame/roms", }, - Config: &Config{ - BindWorkdir: false, - ValidVolumes: []string{"**"}, - Secrets: map[string]string{ - "MAME": "/host/mame/roms", - }, - }, - } + }) rc.Run.JobID = "job1" rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) @@ -576,20 +494,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { for _, testcase := range tests { t.Run(testcase.name, func(t *testing.T) { - rc := &RunContext{ - Name: "TestRCName", - Run: &model.Run{ - Workflow: &model.Workflow{ - Name: "TestWorkflowName", - }, - }, - Config: &Config{ - BindWorkdir: false, - SharedToolCache: true, // so OverridesToolCache has a mount to displace - ValidVolumes: []string{"**"}, - }, - containerSpec: model.ContainerSpec{Volumes: testcase.volumes}, - } + rc := newRunContext(&Config{ + SharedToolCache: true, + ValidVolumes: []string{"**"}, + }) + rc.containerSpec.Volumes = testcase.volumes rc.Run.JobID = "job1" gotbind, gotmount := rc.GetBindsAndMounts(t.Context()) @@ -603,8 +512,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { assert.Equal(t, gotmount[k], v) } - // Docker rejects a container with two mounts on one target, so the job's own - // volumes must displace the runner's rather than pile up next to them. targets := map[string]bool{} for _, bind := range gotbind { parsed, err := container.ParseVolume(bind) @@ -621,11 +528,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { }) t.Run("ToolCacheMount", func(t *testing.T) { - rc := &RunContext{ - Name: "TestRCName", - Run: &model.Run{Workflow: &model.Workflow{Name: "TestWorkflowName"}}, - Config: &Config{}, - } + rc := newRunContext(&Config{}) _, gotmount := rc.GetBindsAndMounts(t.Context()) assert.NotContains(t, gotmount, sharedToolCacheVolume) @@ -668,15 +571,11 @@ func TestRunContextValidVolumes(t *testing.T) { got := rc.validVolumes() - // the configured volumes plus the ones the runner mounts automatically assert.Subset(t, got, []string{"my-vol", "/host/path", sharedToolCacheVolume, name, name + "-env", "/var/run/docker.sock"}) - // deriving the list must never mutate or grow the shared Config slice: parallel matrix - // combinations share one *Config, and the previous in-place append was a data race. assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes) assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate") - // a job may mount it only while the runner does rc.Config.SharedToolCache = false assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume) @@ -894,7 +793,6 @@ func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) { require.Equal(t, map[string]string{"o": "from-gitea"}, needed.Outputs) } -// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw. func TestInterpolateOutputsFromExpression(t *testing.T) { var rawOutputs yaml.Node require.NoError(t, rawOutputs.Encode(`${{ fromJSON('{"o":"resolved"}') }}`)) @@ -912,7 +810,7 @@ func TestGetGitHubContext(t *testing.T) { logging.SetLevel(logging.LevelDebug) cwd, err := os.Getwd() - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) rc := &RunContext{ Config: &Config{ @@ -939,8 +837,6 @@ func TestGetGitHubContext(t *testing.T) { ghc := rc.getGithubContext(t.Context()) - logging.Debugf("%v", ghc) - actor := "nektos/act" if a := os.Getenv("ACT_ACTOR"); a != "" { actor = a @@ -956,15 +852,15 @@ func TestGetGitHubContext(t *testing.T) { owner = o } - assert.Equal(t, ghc.RunID, "1") //nolint:testifylint // pre-existing issue from nektos/act - assert.Equal(t, ghc.RunNumber, "1") //nolint:testifylint // pre-existing issue from nektos/act - assert.Equal(t, ghc.RetentionDays, "0") //nolint:testifylint // pre-existing issue from nektos/act - assert.Equal(t, ghc.Actor, actor) - assert.Equal(t, ghc.Repository, repo) - assert.Equal(t, ghc.RepositoryOwner, owner) - assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act - assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"]) - assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act + assert.Equal(t, "1", ghc.RunID) + assert.Equal(t, "1", ghc.RunNumber) + assert.Equal(t, "0", ghc.RetentionDays) + assert.Equal(t, actor, ghc.Actor) + assert.Equal(t, repo, ghc.Repository) + assert.Equal(t, owner, ghc.RepositoryOwner) + assert.Equal(t, "/dev/null", ghc.RunnerPerflog) + assert.Equal(t, rc.Config.Secrets["GITHUB_TOKEN"], ghc.Token) + assert.Equal(t, "job1", ghc.Job) rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"} ghc = rc.getGithubContext(t.Context()) @@ -1070,229 +966,88 @@ func createJob(t *testing.T, input, result string) *model.Job { } func TestRunContextRunsOnPlatformNames(t *testing.T) { - logging.SetLevel(logging.LevelDebug) - assertObject := assert.New(t) - - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }) - assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ${{ 'ubuntu-latest' }}`, ""), - }) - assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: [self-hosted, my-runner]`, ""), - }) - assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: [self-hosted, "${{ 'my-runner' }}"]`, ""), - }) - assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`, ""), - }) - assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context())) - - // test missing / invalid runs-on - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `name: something`, ""), - }) - assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: - mapping: value`, ""), - }) - assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ${{ invalid expression }}`, ""), - }) - assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context())) -} - -func TestRunContextIsEnabled(t *testing.T) { - logging.SetLevel(logging.LevelDebug) - assertObject := assert.New(t) - - // success() - rc := createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest -env: - BROKEN: ${{ fromJSON('invalid') }} -if: success()`, ""), - }) - assertObject.True(rc.isEnabled(t.Context())) - require.Error(t, evaluateJobEnvAndDefaults(t.Context(), rc)) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: success()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.False(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "success"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: success()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), - "job2": createJob(t, `runs-on: ubuntu-latest -if: success()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - // failure() - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest -if: failure()`, ""), - }) - assertObject.False(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: failure()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "success"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: failure()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.False(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), - "job2": createJob(t, `runs-on: ubuntu-latest -if: failure()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.False(rc.isEnabled(t.Context())) - - // always() - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest -if: always()`, ""), - }) - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: always()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "success"), - "job2": createJob(t, `runs-on: ubuntu-latest -needs: [job1] -if: always()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, "success"), - "job2": createJob(t, `runs-on: ubuntu-latest -if: always()`, ""), - }) - rc.Run.JobID = "job2" - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `uses: ./.github/workflows/reusable.yml`, ""), - }) - assertObject.True(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `uses: ./.github/workflows/reusable.yml -if: false`, ""), - }) - assertObject.False(rc.isEnabled(t.Context())) - - rc = createIfTestRunContext(map[string]*model.Job{ - "job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""), - }) - enabled, err := rc.isEnabled(t.Context()) - require.NoError(t, err) - assertObject.False(enabled) -} - -func TestRunContextGetEnv(t *testing.T) { - tests := []struct { - description string - rc *RunContext - targetEnv string - want string + for _, testcase := range []struct { + job string + want []string }{ - { - description: "Env from Config should overwrite", - rc: &RunContext{ - Config: &Config{ - Env: map[string]string{"OVERWRITTEN": "true"}, - }, - Run: &model.Run{ - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{"test": {Name: "test"}}, - Env: map[string]string{"OVERWRITTEN": "false"}, - }, - JobID: "test", - }, - }, - targetEnv: "OVERWRITTEN", - want: "true", - }, - { - description: "No overwrite occurs", - rc: &RunContext{ - Config: &Config{ - Env: map[string]string{"SOME_OTHER_VAR": "true"}, - }, - Run: &model.Run{ - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{"test": {Name: "test"}}, - Env: map[string]string{"OVERWRITTEN": "false"}, - }, - JobID: "test", - }, - }, - targetEnv: "OVERWRITTEN", - want: "false", - }, - } - - for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - envMap := test.rc.GetEnv() - assert.EqualValues(t, test.want, envMap[test.targetEnv]) //nolint:testifylint // pre-existing issue from nektos/act + {"runs-on: ubuntu-latest", []string{"ubuntu-latest"}}, + {"runs-on: ${{ 'ubuntu-latest' }}", []string{"ubuntu-latest"}}, + {"runs-on: [self-hosted, my-runner]", []string{"self-hosted", "my-runner"}}, + {`runs-on: [self-hosted, "${{ 'my-runner' }}"]`, []string{"self-hosted", "my-runner"}}, + {`runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`, []string{"ubuntu-latest"}}, + {"name: something", []string{}}, + {"runs-on: {mapping: value}", []string{}}, + {"runs-on: ${{ invalid expression }}", []string{}}, + } { + t.Run(testcase.job, func(t *testing.T) { + rc := createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, testcase.job, "")}) + assert.Equal(t, testcase.want, rc.runsOnPlatformNames(t.Context())) + }) + } +} + +func TestRunContextIsEnabled(t *testing.T) { + for _, testcase := range []struct { + name, job, neededResult string + want, envError bool + }{ + {name: "broken env fails at setup", job: "runs-on: ubuntu-latest\nenv:\n BROKEN: ${{ fromJSON('invalid') }}\nif: success()", want: true, envError: true}, + {name: "success with failed dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: success()", neededResult: "failure"}, + {name: "success with successful dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: success()", neededResult: "success", want: true}, + {name: "success with unrelated failed job", job: "runs-on: ubuntu-latest\nif: success()", neededResult: "failure", want: true}, + {name: "failure without dependencies", job: "runs-on: ubuntu-latest\nif: failure()"}, + {name: "failure with failed dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: failure()", neededResult: "failure", want: true}, + {name: "failure with successful dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: failure()", neededResult: "success"}, + {name: "failure with unrelated failed job", job: "runs-on: ubuntu-latest\nif: failure()", neededResult: "failure"}, + {name: "always without dependencies", job: "runs-on: ubuntu-latest\nif: always()", want: true}, + {name: "always with failed dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: always()", neededResult: "failure", want: true}, + {name: "always with successful dependency", job: "runs-on: ubuntu-latest\nneeds: [needed]\nif: always()", neededResult: "success", want: true}, + {name: "always with unrelated successful job", job: "runs-on: ubuntu-latest\nif: always()", neededResult: "success", want: true}, + {name: "reusable workflow", job: "uses: ./.github/workflows/reusable.yml", want: true}, + {name: "disabled reusable workflow", job: "uses: ./.github/workflows/reusable.yml\nif: false"}, + {name: "invalid condition", job: "if: ${{ fromJSON('not-json') }}"}, + } { + t.Run(testcase.name, func(t *testing.T) { + jobs := map[string]*model.Job{"job1": createJob(t, testcase.job, "")} + if testcase.neededResult != "" { + jobs["needed"] = createJob(t, "runs-on: ubuntu-latest", testcase.neededResult) + } + rc := createIfTestRunContext(jobs) + enabled, err := rc.isEnabled(t.Context()) + require.NoError(t, err) + assert.Equal(t, testcase.want, enabled) + if testcase.envError { + require.Error(t, evaluateJobEnvAndDefaults(t.Context(), rc)) + } + }) + } +} + +func TestRunContextGetEnv(t *testing.T) { + for _, testcase := range []struct { + name string + env map[string]string + want string + }{ + {"Env from Config should overwrite", map[string]string{"OVERWRITTEN": "true"}, "true"}, + {"No overwrite occurs", map[string]string{"SOME_OTHER_VAR": "true"}, "false"}, + } { + t.Run(testcase.name, func(t *testing.T) { + rc := &RunContext{ + Config: &Config{Env: testcase.env}, + Run: &model.Run{ + JobID: "test", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{"test": {}}, + Env: map[string]string{"OVERWRITTEN": "false"}, + }, + }, + } + assert.Equal(t, testcase.want, rc.GetEnv()["OVERWRITTEN"]) }) } } -// a remote composite action re-evaluates its env per stage, so inputs must follow it func TestSetActionEnvRefreshesInputs(t *testing.T) { rc := &RunContext{} rc.setActionEnv(map[string]string{"INPUT_MSG": "pre"}) @@ -1452,15 +1207,40 @@ func TestWaitForServiceContainers(t *testing.T) { } } - t.Run("returns as soon as a service without a healthcheck runs", func(t *testing.T) { - service := &containerMock{} - service.On("Inspect", mock.Anything). - Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once() - - rc := newRunContext(0, &serviceContainer{name: "redis", container: service}) - require.NoError(t, rc.waitForServiceContainers()(t.Context())) - service.AssertExpectations(t) - }) + for _, testcase := range []struct { + name, serviceName string + timeout time.Duration + info *container.Info + inspectError error + wantError, wantID string + }{ + {name: "returns as soon as a service without a healthcheck runs", serviceName: "redis", info: &container.Info{ID: "id", State: "running", Health: container.HealthNone}}, + {name: "fails with the probe output when a service is unhealthy", serviceName: "postgres", info: &container.Info{State: "running", Health: container.HealthUnhealthy, HealthOutput: "connection refused"}, wantError: "the service 'postgres' is unhealthy: connection refused"}, + {name: "lets the steps run when a service exits without a healthcheck", serviceName: "postgres", info: &container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}}, + {name: "proceeds when the container is gone", serviceName: "postgres", inspectError: container.ErrContainerNotFound}, + {name: "does not wait when the timeout is negative", serviceName: "postgres", timeout: -1, info: &container.Info{ID: "id", State: "running", Health: container.HealthStarting}, wantID: "id"}, + {name: "fails on an inspect error even when the timeout is negative", serviceName: "postgres", timeout: -1, inspectError: errors.New("daemon is gone"), wantError: "failed to inspect service 'postgres'"}, + } { + t.Run(testcase.name, func(t *testing.T) { + service := &containerMock{} + service.On("Inspect", mock.Anything).Return(testcase.info, testcase.inspectError).Once() + if testcase.info != nil && testcase.info.Health == container.HealthUnhealthy { + service.On("DumpLogs", mock.Anything).Return(nil).Once() + } + svc := &serviceContainer{name: testcase.serviceName, container: service} + err := newRunContext(testcase.timeout, svc).waitForServiceContainers()(t.Context()) + if testcase.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), testcase.wantError) + } else { + require.NoError(t, err) + } + if testcase.wantID != "" { + assert.Equal(t, testcase.wantID, svc.info.ID) + } + service.AssertExpectations(t) + }) + } t.Run("polls at a fixed interval and logs starting only once", func(t *testing.T) { synctest.Test(t, func(t *testing.T) { @@ -1483,40 +1263,6 @@ func TestWaitForServiceContainers(t *testing.T) { }) }) - t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) { - service := &containerMock{} - service.On("Inspect", mock.Anything).Return(&container.Info{ - State: "running", - Health: container.HealthUnhealthy, - HealthOutput: "connection refused", - }, nil).Once() - service.On("DumpLogs", mock.Anything).Return(nil).Once() - - rc := newRunContext(0, &serviceContainer{name: "postgres", container: service}) - err := rc.waitForServiceContainers()(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused") - service.AssertExpectations(t) - }) - - t.Run("lets the steps run when a service exits without a healthcheck", func(t *testing.T) { - service := &containerMock{} - service.On("Inspect", mock.Anything). - Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once() - - rc := newRunContext(0, &serviceContainer{name: "postgres", container: service}) - require.NoError(t, rc.waitForServiceContainers()(t.Context())) - }) - - t.Run("proceeds when the container is gone", func(t *testing.T) { - service := &containerMock{} - service.On("Inspect", mock.Anything). - Return((*container.Info)(nil), container.ErrContainerNotFound).Once() - - rc := newRunContext(0, &serviceContainer{name: "postgres", container: service}) - require.NoError(t, rc.waitForServiceContainers()(t.Context())) - }) - t.Run("fails right away when one service fails while another is still starting", func(t *testing.T) { failing := &containerMock{} failing.On("Inspect", mock.Anything). @@ -1565,29 +1311,6 @@ func TestWaitForServiceContainers(t *testing.T) { assert.Contains(t, err.Error(), "did not become healthy within") }) - t.Run("does not wait when the timeout is negative", func(t *testing.T) { - service := &containerMock{} - // Still described once, so the `job.services` context is filled either way. - service.On("Inspect", mock.Anything). - Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Once() - - svc := &serviceContainer{name: "postgres", container: service} - rc := newRunContext(-1, svc) - require.NoError(t, rc.waitForServiceContainers()(t.Context())) - service.AssertExpectations(t) - assert.Equal(t, "id", svc.info.ID) - }) - - t.Run("fails on an inspect error even when the timeout is negative", func(t *testing.T) { - service := &containerMock{} - service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once() - - rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service}) - err := rc.waitForServiceContainers()(t.Context()) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to inspect service 'postgres'") - }) - t.Run("is a no-op without services", func(t *testing.T) { require.NoError(t, newRunContext(0).waitForServiceContainers()(t.Context())) }) @@ -1616,9 +1339,7 @@ func TestGetJobContextReportsContainers(t *testing.T) { jobContainerID: "job-container-id", serviceContainers: []*serviceContainer{ {name: "postgres", info: &container.Info{ID: "svc-id", Ports: map[string]string{"5432": "49153"}}}, - // A service that publishes no port reports an empty map, as GitHub does. {name: "redis", info: &container.Info{ID: "redis-id", Ports: map[string]string{}}}, - // A service that never reported is left out rather than reported as empty. {name: "mailhog"}, }, } @@ -1656,7 +1377,6 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) { assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE") } -// A job that never started a container reports an empty context, not a placeholder. func TestGetJobContextWithoutContainer(t *testing.T) { jobContext := (&RunContext{}).getJobContext() diff --git a/act/runner/runner.go b/act/runner/runner.go index 5da3c922..cfb4532b 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -101,11 +101,7 @@ func (c Config) RunnerDebug() bool { // GetToken: Adapt to Gitea func (c Config) GetToken() string { - token := c.Secrets["GITHUB_TOKEN"] - if c.Secrets["GITEA_TOKEN"] != "" { - token = c.Secrets["GITEA_TOKEN"] - } - return token + return cmp.Or(c.Secrets["GITEA_TOKEN"], c.Secrets["GITHUB_TOKEN"]) } // DefaultActionURL returns the host used for implicit remote actions. @@ -185,8 +181,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { stagePipeline := make([]common.Executor, 0) logging.Debugf("Plan Stages: %v", plan.Stages) - for i := range plan.Stages { - stage := plan.Stages[i] + for _, stage := range plan.Stages { stagePipeline = append(stagePipeline, func(ctx context.Context) error { pipeline := make([]common.Executor, 0) for _, run := range stage.Runs { @@ -268,9 +263,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { if len(matrixes) > 1 { rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1) } - if len(rc.String()) > maxJobNameLen { - maxJobNameLen = len(rc.String()) - } + maxJobNameLen = max(maxJobNameLen, len(rc.String())) if rc.caller != nil { // For Gitea rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "pending") } @@ -322,10 +315,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { logging.Debugf("Using CPU count for parallelism: %d", parallelism) } - // Don't exceed the number of pipeline elements - if parallelism > len(pipeline) { - parallelism = len(pipeline) - } + parallelism = min(parallelism, len(pipeline)) logging.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism) return common.NewParallelExecutor(parallelism, pipeline...)(ctx) diff --git a/act/runner/step.go b/act/runner/step.go index 86326983..42984557 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -215,7 +215,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo } else { stepResult.Outcome = model.StepStatusFailure - continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage) + continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step) if parseErr != nil { stepResult.Conclusion = model.StepStatusFailure logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString) @@ -254,7 +254,10 @@ func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, ste func setupEnv(ctx context.Context, step step) error { rc := step.getRunContext() - mergeEnv(ctx, step) + env := step.getEnv() + // container env is the image's baseline, which job env and $GITHUB_ENV override + mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv()) + rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env) var err error exprEval := rc.NewExpressionEvaluator(ctx) @@ -282,16 +285,6 @@ func setupInputs(ctx context.Context, step step) error { return nil } -func mergeEnv(ctx context.Context, step step) { - env := step.getEnv() - rc := step.getRunContext() - - // container env is the image's baseline, which job env and $GITHUB_ENV override - mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv()) - - rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env) -} - func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) (bool, error) { rc := step.getRunContext() @@ -315,9 +308,9 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) return runStep, nil } -func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage) (bool, error) { +func isContinueOnError(ctx context.Context, expr string, step step) (bool, error) { // https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962 - if len(strings.TrimSpace(expr)) == 0 { + if strings.TrimSpace(expr) == "" { return false, nil } @@ -355,7 +348,7 @@ func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]st if k, ok := foldKeys[foldKey]; ok { return k } - foldKeys[strings.ToLower(foldKey)] = s + foldKeys[foldKey] = s return s } for _, m := range maps { @@ -366,8 +359,7 @@ func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]st } func symlinkJoin(filename, sym, parent string) (string, error) { - dir := path.Dir(filename) - dest := path.Join(dir, sym) + dest := path.Join(path.Dir(filename), sym) prefix := path.Clean(parent) + "/" if strings.HasPrefix(dest, prefix) || prefix == "./" { return dest, nil diff --git a/act/runner/step_action_local_test.go b/act/runner/step_action_local_test.go index b6c09497..49d9c849 100644 --- a/act/runner/step_action_local_test.go +++ b/act/runner/step_action_local_test.go @@ -17,28 +17,37 @@ import ( "gitea.dev/actionslib/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "go.yaml.in/yaml/v4" + "github.com/stretchr/testify/require" ) -type stepActionLocalMocks struct { +type stepActionMocks struct { mock.Mock } -func (salm *stepActionLocalMocks) runAction(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { +func (salm *stepActionMocks) runAction(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { args := salm.Called(step, actionDir, remoteAction) return args.Get(0).(func(context.Context) error) } -func (salm *stepActionLocalMocks) readAction(_ context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { +func (salm *stepActionMocks) readAction(_ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { args := salm.Called(step, actionDir, actionPath, readFile, writeFile) return args.Get(0).(*model.Action), args.Error(1) } +func expectStepFileCommands(t *testing.T, cm *containerMock) { + t.Helper() + cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) + for _, filename := range []string{"envs.txt", "statecmd.txt", "outputcmd.txt"} { + cm.On("UpdateFromEnv", "/var/run/act/workflow/"+filename, mock.AnythingOfType("*map[string]string")).Return(noopExecutor) + } + cm.On("GetContainerArchive", t.Context(), "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) +} + func TestStepActionLocalTest(t *testing.T) { ctx := t.Context() cm := &containerMock{} - salm := &stepActionLocalMocks{} + salm := &stepActionMocks{} sal := &stepActionLocal{ readAction: salm.readAction, @@ -53,13 +62,7 @@ func TestStepActionLocalTest(t *testing.T) { JobID: "1", Workflow: &model.Workflow{ Jobs: map[string]*model.Job{ - "1": { - Defaults: model.Defaults{ - Run: model.RunDefaults{ - Shell: "bash", - }, - }, - }, + "1": {}, }, }, }, @@ -74,208 +77,87 @@ func TestStepActionLocalTest(t *testing.T) { salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything). Return(&model.Action{}, nil) - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) + expectStepFileCommands(t, cm) salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(noopExecutor) err := sal.pre()(ctx) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) err = sal.main()(ctx) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + require.NoError(t, err) cm.AssertExpectations(t) salm.AssertExpectations(t) } -func TestStepActionLocalPost(t *testing.T) { - table := []struct { - name string - stepModel *model.Step - actionModel *model.Action - initialStepResults map[string]*model.StepResult - err error - mocks struct { - env bool - exec bool - } - }{ - { - name: "main-success", - stepModel: &model.Step{ - ID: "step", - Uses: "./local/action", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusSuccess, - Outcome: model.StepStatusSuccess, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool +func TestStepActionPost(t *testing.T) { + for _, kind := range []string{"local", "remote"} { + t.Run(kind, func(t *testing.T) { + for _, test := range []struct { + name string + status model.StepStatus + postIf string + run bool }{ - env: true, - exec: true, - }, - }, - { - name: "main-failed", - stepModel: &model.Step{ - ID: "step", - Uses: "./local/action", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusFailure, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: true, - exec: true, - }, - }, - { - name: "skip-if-failed", - stepModel: &model.Step{ - ID: "step", - Uses: "./local/action", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "success()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusFailure, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: false, - exec: false, - }, - }, - { - name: "skip-if-main-skipped", - stepModel: &model.Step{ - ID: "step", - If: yaml.Node{Value: "failure()"}, - Uses: "./local/action", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusSkipped, - Outcome: model.StepStatusSkipped, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: false, - exec: false, - }, - }, - } + {"main-success", model.StepStatusSuccess, "always()", true}, + {"main-failed", model.StepStatusFailure, "always()", true}, + {"skip-if-failed", model.StepStatusFailure, "success()", false}, + {"skip-if-main-skipped", model.StepStatusSkipped, "always()", false}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := t.Context() + cm := &containerMock{} + rc := &RunContext{ + Config: &Config{GitHubInstance: "https://github.com", ActionCacheDir: t.TempDir()}, + JobContainer: cm, + Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, + StepResults: map[string]*model.StepResult{"step": {Conclusion: test.status}}, + } + rc.ExprEval = rc.NewExpressionEvaluator(ctx) + action := &model.Action{Runs: model.ActionRuns{Using: "node16", Post: "post.js", PostIf: test.postIf}} + var currentStep step + if kind == "remote" { + currentStep = &stepActionRemote{ + RunContext: rc, + Step: &model.Step{ID: "step", Uses: "remote/action@v1"}, + action: action, + env: map[string]string{}, + remoteAction: mustNewRemoteAction(t, "remote/action@v1"), + } + if test.status == model.StepStatusSuccess { + rc.IntraActionState = map[string]map[string]string{"step": {"key": "value"}} + } + } else { + currentStep = &stepActionLocal{ + RunContext: rc, + Step: &model.Step{ID: "step", Uses: "./local/action"}, + action: action, + env: map[string]string{}, + } + } - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - ctx := t.Context() + if test.run { + cm.On("Exec", mock.MatchedBy(func(args []string) bool { + if len(args) != 3 || args[0] != "node" || args[1] != "--preserve-symlinks-main" { + return false + } + if kind == "local" { + return strings.HasSuffix(args[2], "runner/local/action/post.js") + } + return strings.Contains(args[2], "post.js") + }), *currentStep.getEnv(), "", "").Return(noopExecutor) + expectStepFileCommands(t, cm) + } - cm := &containerMock{} - - sal := &stepActionLocal{ - env: map[string]string{}, - RunContext: &RunContext{ - Config: &Config{ - GitHubInstance: "https://github.com", - }, - JobContainer: cm, - Run: &model.Run{ - JobID: "1", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "1": {}, - }, - }, - }, - StepResults: tt.initialStepResults, - }, - Step: tt.stepModel, - action: tt.actionModel, + require.NoError(t, currentStep.post()(ctx)) + if rc.IntraActionState != nil { + assert.Equal(t, "value", (*currentStep.getEnv())["STATE_key"]) + } + assert.NotContains(t, rc.StepResults, "post-step") + cm.AssertExpectations(t) + }) } - sal.RunContext.ExprEval = sal.RunContext.NewExpressionEvaluator(ctx) - - if tt.mocks.exec { - suffixMatcher := func(suffix string) any { - return mock.MatchedBy(func(array []string) bool { - return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" && - strings.HasSuffix(array[2], suffix) - }) - } - cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err }) - - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) - } - - err := sal.post()(ctx) - - assert.Equal(t, tt.err, err) - assert.Equal(t, sal.RunContext.StepResults["post-step"], (*model.StepResult)(nil)) //nolint:testifylint // pre-existing issue from nektos/act - cm.AssertExpectations(t) }) } } diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index dd5f4880..28401f92 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -290,10 +290,7 @@ func (ra *remoteAction) Reference() string { } func (ra *remoteAction) IsCheckout() bool { - if ra.Org == "actions" && ra.Repo == "checkout" { - return true - } - return false + return ra.Org == "actions" && ra.Repo == "checkout" } // newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit. diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 9c8fd10b..917a9b6a 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -5,11 +5,9 @@ package runner import ( - "bytes" + "cmp" "context" - "errors" "fmt" - "io" "os" "os/exec" "path" @@ -28,10 +26,6 @@ import ( "go.yaml.in/yaml/v4" ) -type stepActionRemoteMocks struct { - mock.Mock -} - func setFetch(t *testing.T, fetch func(context.Context, git.NewGitCloneExecutorInput) (string, string, error)) { original := stepActionRemoteFetch stepActionRemoteFetch = fetch @@ -74,108 +68,21 @@ func TestShortSHAActionRejected(t *testing.T) { require.ErrorContains(t, err, "shortened version of a commit SHA") } -func (sarm *stepActionRemoteMocks) readAction(_ context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { - args := sarm.Called(step, actionDir, actionPath, readFile, writeFile) - return args.Get(0).(*model.Action), args.Error(1) -} - -func (sarm *stepActionRemoteMocks) runAction(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { - args := sarm.Called(step, actionDir, remoteAction) - return args.Get(0).(func(context.Context) error) -} - func TestStepActionRemote(t *testing.T) { - table := []struct { - name string - stepModel *model.Step - result *model.StepResult - mocks struct { - env bool - cloned bool - read bool - run bool - } - runError error + for _, tt := range []struct { + name, condition string + status model.StepStatus + runError error }{ - { - name: "run-successful", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - }, - result: &model.StepResult{ - Conclusion: model.StepStatusSuccess, - Outcome: model.StepStatusSuccess, - Outputs: map[string]string{}, - }, - mocks: struct { - env bool - cloned bool - read bool - run bool - }{ - env: true, - cloned: true, - read: true, - run: true, - }, - }, - { - name: "run-skipped", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - If: yaml.Node{Value: "false"}, - }, - result: &model.StepResult{ - Conclusion: model.StepStatusSkipped, - Outcome: model.StepStatusSkipped, - Outputs: map[string]string{}, - }, - mocks: struct { - env bool - cloned bool - read bool - run bool - }{ - env: true, - cloned: true, - read: true, - run: false, - }, - }, - { - name: "run-error", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - }, - result: &model.StepResult{ - Conclusion: model.StepStatusFailure, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{}, - }, - mocks: struct { - env bool - cloned bool - read bool - run bool - }{ - env: true, - cloned: true, - read: true, - run: true, - }, - runError: errors.New("error"), - }, - } - - for _, tt := range table { + {name: "run-successful", status: model.StepStatusSuccess}, + {name: "run-skipped", condition: "false", status: model.StepStatusSkipped}, + {name: "run-error", status: model.StepStatusFailure, runError: assert.AnError}, + } { t.Run(tt.name, func(t *testing.T) { ctx := t.Context() cm := &containerMock{} - sarm := &stepActionRemoteMocks{} + sarm := &stepActionMocks{} clonedAction := false actionDir := t.TempDir() @@ -201,37 +108,23 @@ func TestStepActionRemote(t *testing.T) { StepResults: map[string]*model.StepResult{}, JobContainer: cm, }, - Step: tt.stepModel, + Step: &model.Step{ID: "step", Uses: "remote/action@v1", If: yaml.Node{Value: tt.condition}}, readAction: sarm.readAction, runAction: sarm.runAction, } sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) - if tt.mocks.read { - sarm.On("readAction", sar.Step, actionDir, "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) - } - if tt.mocks.run { - sarm.On("runAction", sar, actionDir, mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError }) + sarm.On("readAction", sar.Step, actionDir, "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) + if tt.status != model.StepStatusSkipped { + sarm.On("runAction", sar, actionDir, mustNewRemoteAction(t, sar.Step.Uses)).Return(func(_ctx context.Context) error { return tt.runError }) - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) + expectStepFileCommands(t, cm) } - err := sar.pre()(ctx) - if err == nil { - err = sar.main()(ctx) - } - - assert.Equal(t, tt.runError, err) - assert.Equal(t, tt.mocks.cloned, clonedAction) - assert.Equal(t, tt.result, sar.RunContext.StepResults["step"]) + require.NoError(t, sar.pre()(ctx)) + assert.Equal(t, tt.runError, sar.main()(ctx)) + assert.True(t, clonedAction) + assert.Equal(t, &model.StepResult{Conclusion: tt.status, Outcome: tt.status, Outputs: map[string]string{}}, sar.RunContext.StepResults["step"]) sarm.AssertExpectations(t) cm.AssertExpectations(t) @@ -314,341 +207,33 @@ func TestStepActionRemotePrepare(t *testing.T) { assert.Equal(t, "restore", path.Base(restoreContainerDir)) } -func TestStepActionRemotePost(t *testing.T) { - table := []struct { - name string - stepModel *model.Step - actionModel *model.Action - initialStepResults map[string]*model.StepResult - IntraActionState map[string]map[string]string - expectedEnv map[string]string - err error - mocks struct { - env bool - exec bool - } - }{ - { - name: "main-success", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusSuccess, - Outcome: model.StepStatusSuccess, - Outputs: map[string]string{}, - }, - }, - IntraActionState: map[string]map[string]string{ - "step": { - "key": "value", - }, - }, - expectedEnv: map[string]string{ - "STATE_key": "value", - }, - mocks: struct { - env bool - exec bool - }{ - env: true, - exec: true, - }, - }, - { - name: "main-failed", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusFailure, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: true, - exec: true, - }, - }, - { - name: "skip-if-failed", - stepModel: &model.Step{ - ID: "step", - Uses: "remote/action@v1", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "success()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusFailure, - Outcome: model.StepStatusFailure, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: true, - exec: false, - }, - }, - { - name: "skip-if-main-skipped", - stepModel: &model.Step{ - ID: "step", - If: yaml.Node{Value: "failure()"}, - Uses: "remote/action@v1", - }, - actionModel: &model.Action{ - Runs: model.ActionRuns{ - Using: "node16", - Post: "post.js", - PostIf: "always()", - }, - }, - initialStepResults: map[string]*model.StepResult{ - "step": { - Conclusion: model.StepStatusSkipped, - Outcome: model.StepStatusSkipped, - Outputs: map[string]string{}, - }, - }, - mocks: struct { - env bool - exec bool - }{ - env: false, - exec: false, - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - ctx := t.Context() - - cm := &containerMock{} - - sar := &stepActionRemote{ - env: map[string]string{}, - RunContext: &RunContext{ - Config: &Config{ - GitHubInstance: "https://github.com", - ActionCacheDir: t.TempDir(), - }, - JobContainer: cm, - Run: &model.Run{ - JobID: "1", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "1": {}, - }, - }, - }, - StepResults: tt.initialStepResults, - IntraActionState: tt.IntraActionState, - }, - Step: tt.stepModel, - action: tt.actionModel, - // post only ever runs after prepareActionExecutor resolved the action - remoteAction: mustNewRemoteAction(t, tt.stepModel.Uses), - } - sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) - - if tt.mocks.exec { - // Use mock.MatchedBy to match the exec command with hash-based path - execMatcher := mock.MatchedBy(func(args []string) bool { - if len(args) != 3 { - return false - } - return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js") - }) - - cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err }) - - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) - } - - err := sar.post()(ctx) - - assert.Equal(t, tt.err, err) - if tt.expectedEnv != nil { - for key, value := range tt.expectedEnv { - assert.Equal(t, value, sar.env[key]) - } - } - // Enshure that StepResults is nil in this test - assert.Equal(t, sar.RunContext.StepResults["post-step"], (*model.StepResult)(nil)) //nolint:testifylint // pre-existing issue from nektos/act - cm.AssertExpectations(t) - }) - } -} - func Test_newRemoteAction(t *testing.T) { - tests := []struct { - action string - want *remoteAction - wantCloneURL string - }{ - { - action: "actions/heroku@main", - want: &remoteAction{ - URL: "", - Org: "actions", - Repo: "heroku", - Path: "", - Ref: "main", - }, - wantCloneURL: "https://github.com/actions/heroku", - }, - { - action: "actions/aws/ec2@main", - want: &remoteAction{ - URL: "", - Org: "actions", - Repo: "aws", - Path: "ec2", - Ref: "main", - }, - wantCloneURL: "https://github.com/actions/aws", - }, - { - action: "./.github/actions/my-action", // it's valid for GitHub, but act don't support it - want: nil, - }, - { - action: "docker://alpine:3.8", // it's valid for GitHub, but act don't support it - want: nil, - }, - { - action: "https://gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it - want: &remoteAction{ - URL: "https://gitea.com", - Org: "actions", - Repo: "heroku", - Path: "", - Ref: "main", - }, - wantCloneURL: "https://gitea.com/actions/heroku", - }, - { - action: "https://gitea.com/actions/aws/ec2@main", // it's invalid for GitHub, but gitea supports it - want: &remoteAction{ - URL: "https://gitea.com", - Org: "actions", - Repo: "aws", - Path: "ec2", - Ref: "main", - }, - wantCloneURL: "https://gitea.com/actions/aws", - }, - { - action: "http://gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it - want: &remoteAction{ - URL: "http://gitea.com", - Org: "actions", - Repo: "heroku", - Path: "", - Ref: "main", - }, - wantCloneURL: "http://gitea.com/actions/heroku", - }, - { - action: "http://gitea.com/actions/aws/ec2@main", // it's invalid for GitHub, but gitea supports it - want: &remoteAction{ - URL: "http://gitea.com", - Org: "actions", - Repo: "aws", - Path: "ec2", - Ref: "main", - }, - wantCloneURL: "http://gitea.com/actions/aws", - }, - { - action: "ssh://git@gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it - want: &remoteAction{ - URL: "ssh://git@gitea.com", - Org: "actions", - Repo: "heroku", - Path: "", - Ref: "main", - }, - wantCloneURL: "ssh://git@gitea.com/actions/heroku", - }, - { - action: "ssh://git@gitea.com/actions/aws/ec2@main", // the ssh user is kept as part of the host segment - want: &remoteAction{ - URL: "ssh://git@gitea.com", - Org: "actions", - Repo: "aws", - Path: "ec2", - Ref: "main", - }, - wantCloneURL: "ssh://git@gitea.com/actions/aws", - }, - { - action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host - want: nil, - }, - { - action: "self:owner/repo/sub@v1", - want: &remoteAction{ - URL: "https://gitea.example.com", - Org: "owner", - Repo: "repo", - Path: "sub", - Ref: "v1", - }, - wantCloneURL: "https://gitea.example.com/owner/repo", - }, - } - for _, tt := range tests { - t.Run(tt.action, func(t *testing.T) { - got, _ := newRemoteAction(tt.action, nil, "gitea.example.com") - assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action) + check := func(action string, want *remoteAction, wantCloneURL string) { + t.Run(action, func(t *testing.T) { + got, _ := newRemoteAction(action, nil, "gitea.example.com") + assert.Equalf(t, want, got, "newRemoteAction(%v)", action) cloneURL := "" if got != nil { cloneURL = got.CloneURL("github.com") } - assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action) + assert.Equalf(t, wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", action) }) } + for _, instance := range []string{"", "https://gitea.com", "http://gitea.com", "ssh://git@gitea.com"} { + for _, repository := range []struct{ name, path string }{{"heroku", ""}, {"aws", "ec2"}} { + action := path.Join("actions", repository.name, repository.path) + "@main" + if instance != "" { + action = instance + "/" + action + } + check(action, &remoteAction{URL: instance, Org: "actions", Repo: repository.name, Path: repository.path, Ref: "main"}, + cmp.Or(instance, "https://github.com")+"/actions/"+repository.name) + } + } + for _, action := range []string{"./.github/actions/my-action", "docker://alpine:3.8", "ssh://gitea.com/onlyonesegment@main"} { + check(action, nil, "") + } + check("self:owner/repo/sub@v1", &remoteAction{URL: "https://gitea.example.com", Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"}, + "https://gitea.example.com/owner/repo") _, err := newRemoteAction("self:owner/repo@v1", nil, "") require.ErrorContains(t, err, "without a Gitea instance") } @@ -734,7 +319,6 @@ func Test_remoteActionReference(t *testing.T) { }{ {uses: "actions/checkout@v7", want: "actions/checkout@v7"}, {uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"}, - // The download source can be interpolated from a secret and must stay out of the log. {uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"}, } for _, tt := range tests { @@ -744,9 +328,6 @@ func Test_remoteActionReference(t *testing.T) { } } -// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local -// git repository standing in for the actions instance, so the reported commit is the one the -// clone actually checked out. func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) { instance := t.TempDir() actionDir := filepath.Join(instance, "actions", "setup-go") @@ -754,9 +335,6 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"), []byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600)) - // Supply an identity on the commit so the test does not depend on a - // git identity being configured in the environment; a CI runner without - // user.name/user.email would otherwise fail "commit" with exit code 128. for _, args := range [][]string{ {"init", "--initial-branch=main", actionDir}, {"-C", actionDir, "add", "action.yml"}, @@ -814,9 +392,6 @@ func Test_safeFilename(t *testing.T) { } } -// Regression: a nested action in a composite cloned anonymously (401) because the -// composite RunContext nils Config.Secrets. The token must come from github.Token, -// which survives the config copy; the host gate must still withhold it cross-host. func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) { const wantToken = "job-token" @@ -850,7 +425,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) { return actionDir, "", nil }) - sarm := &stepActionRemoteMocks{} + sarm := &stepActionMocks{} sar := &stepActionRemote{ Step: &model.Step{Uses: "org/repo@v1"}, RunContext: &RunContext{ @@ -858,10 +433,8 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) { GitHubInstance: tt.gitHubInstance, DefaultActionInstance: tt.defaultActionInstance, ActionCacheDir: t.TempDir(), - // Mirrors the state of a composite RunContext: job secrets are - // stripped, but the job token is still reachable via Config.Token. - Secrets: nil, - Token: wantToken, + Secrets: nil, + Token: wantToken, }, Run: &model.Run{ JobID: "1", diff --git a/act/runner/step_docker_test.go b/act/runner/step_docker_test.go index 472455c9..ae2a92a9 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -5,8 +5,6 @@ package runner import ( - "bytes" - "io" "slices" "strings" "testing" @@ -15,7 +13,6 @@ import ( "gitea.dev/actionslib/pkg/model" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -24,7 +21,6 @@ func TestStepDockerMain(t *testing.T) { var input *container.NewContainerInput - // mock the new container call origContainerNewContainer := ContainerNewContainer ContainerNewContainer = func(containerInput *container.NewContainerInput) container.ExecutionsEnvironment { input = containerInput @@ -79,22 +75,11 @@ func TestStepDockerMain(t *testing.T) { cm.On("Close").Return(noopExecutor) - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) - - err := sd.main()(ctx) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + expectStepFileCommands(t, cm) + require.NoError(t, sd.main()(ctx)) assert.Equal(t, "node:14", input.Image) - // DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers. assert.Empty(t, input.Username) assert.Empty(t, input.Password) assert.True(t, input.AutoRemove) @@ -102,90 +87,20 @@ func TestStepDockerMain(t *testing.T) { cm.AssertExpectations(t) } -func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) { - for _, tc := range []struct { - name string - allocPTY bool - }{ - {name: "off", allocPTY: false}, - {name: "on", allocPTY: true}, - } { - t.Run(tc.name, func(t *testing.T) { - cm := &containerMock{} - - var captured *container.NewContainerInput - origContainerNewContainer := ContainerNewContainer - ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { - captured = input - return cm - } - defer func() { - ContainerNewContainer = origContainerNewContainer - }() - - ctx := t.Context() - sd := &stepDocker{ - RunContext: &RunContext{ - StepResults: map[string]*model.StepResult{}, - Config: &Config{ - AllocatePTY: tc.allocPTY, - PlatformPicker: func(_ []string) string { - return "node:14" - }, - }, - Run: &model.Run{ - JobID: "1", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{"1": {}}, - }, - }, - JobContainer: cm, - }, - Step: &model.Step{ID: "1", Uses: "docker://node:14"}, - } - sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) - - newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") - assert.Equal(t, tc.allocPTY, captured.AllocatePTY) - }) - } -} - -func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) { +func TestStepDockerNewStepContainer(t *testing.T) { inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable() - cases := []struct { - name string - platform string - path string - expectDefault bool - keepsPath bool + for _, test := range []struct { + name, platform, image, path, greeting string + allocPTY, expectDefault, keepsPath bool }{ - { - name: "docker mode attaches to job container network", - platform: "node:14", - path: inheritedPath, - expectDefault: false, - keepsPath: true, - }, - { - name: "host mode uses default network and the image's PATH", - platform: "-self-hosted", - path: inheritedPath, - expectDefault: true, - }, - { - name: "host mode keeps a PATH that is not the runner's", - platform: "-self-hosted", - path: "/custom/bin", - expectDefault: true, - keepsPath: true, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { + {name: "allocate PTY/off", platform: "node:14", image: "node:14", greeting: "hi"}, + {name: "allocate PTY/on", platform: "node:14", image: "node:14", greeting: "hi", allocPTY: true}, + {name: "docker mode attaches to job container network", platform: "node:14", image: "alpine:3.20", path: inheritedPath, greeting: "hello", keepsPath: true}, + {name: "host mode uses default network and the image's PATH", platform: "-self-hosted", image: "alpine:3.20", path: inheritedPath, greeting: "hello", expectDefault: true}, + {name: "host mode keeps a PATH that is not the runner's", platform: "-self-hosted", image: "alpine:3.20", path: "/custom/bin", greeting: "hello", expectDefault: true, keepsPath: true}, + } { + t.Run(test.name, func(t *testing.T) { cm := &containerMock{} - var captured *container.NewContainerInput origContainerNewContainer := ContainerNewContainer ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { @@ -197,48 +112,38 @@ func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) { }() ctx := t.Context() - - platform := tc.platform sd := &stepDocker{ RunContext: &RunContext{ StepResults: map[string]*model.StepResult{}, Config: &Config{ - PlatformPicker: func(_ []string) string { - return platform - }, - }, - Run: &model.Run{ - JobID: "1", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "1": {}, - }, + AllocatePTY: test.allocPTY, + PlatformPicker: func(_platforms []string) string { + return test.platform }, }, + Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, JobContainer: cm, }, - Step: &model.Step{ - ID: "1", - Uses: "docker://alpine:3.20", - }, - env: map[string]string{"PATH": tc.path}, + Step: &model.Step{ID: "1", Uses: "docker://" + test.image}, } sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) + if test.path != "" { + sd.env = map[string]string{"PATH": test.path} + require.NoError(t, sd.RunContext.resolvePlatformImage(ctx)) + assert.Equal(t, test.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", test.platform) + } - require.NoError(t, sd.RunContext.resolvePlatformImage(ctx)) - assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform) - - newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") - assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path)) - - if tc.expectDefault { - assert.Equal(t, "default", captured.NetworkMode, - "host-mode step container must use 'default' network, got %q", - captured.NetworkMode) - } else { - assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"), - "docker-mode step container must attach to job container network, got %q", - captured.NetworkMode) + newStepContainer(ctx, sd, test.image, []string{"echo", test.greeting}, nil, "") + assert.Equal(t, test.allocPTY, captured.AllocatePTY) + if test.path != "" { + assert.Equal(t, test.keepsPath, slices.Contains(captured.Env, "PATH="+test.path)) + if test.expectDefault { + assert.Equal(t, "default", captured.NetworkMode, + "host-mode step container must use 'default' network, got %q", captured.NetworkMode) + } else { + assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"), + "docker-mode step container must attach to job container network, got %q", captured.NetworkMode) + } } }) } diff --git a/act/runner/step_factory_test.go b/act/runner/step_factory_test.go index 12cfb8d7..21e3cdc9 100644 --- a/act/runner/step_factory_test.go +++ b/act/runner/step_factory_test.go @@ -9,74 +9,25 @@ import ( "gitea.dev/actionslib/pkg/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStepFactoryNewStep(t *testing.T) { - table := []struct { + for _, test := range []struct { name string model *model.Step - check func(s step) bool + want step }{ - { - name: "StepRemoteAction", - model: &model.Step{ - Uses: "remote/action@v1", - }, - check: func(s step) bool { - _, ok := s.(*stepActionRemote) - return ok - }, - }, - { - name: "StepLocalAction", - model: &model.Step{ - Uses: "./action@v1", - }, - check: func(s step) bool { - _, ok := s.(*stepActionLocal) - return ok - }, - }, - { - name: "StepDocker", - model: &model.Step{ - Uses: "docker://image:tag", - }, - check: func(s step) bool { - _, ok := s.(*stepDocker) - return ok - }, - }, - { - name: "StepRun", - model: &model.Step{ - Run: "cmd", - }, - check: func(s step) bool { - _, ok := s.(*stepRun) - return ok - }, - }, - { - name: "StepBuiltinAction", - model: &model.Step{ - Uses: "builtin:checkout", - }, - check: func(s step) bool { - _, ok := s.(*stepActionBuiltin) - return ok - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - sf := &stepFactoryImpl{} - - step, err := sf.newStep(tt.model, &RunContext{}) - - assert.True(t, tt.check(step)) - assert.NoError(t, err) + {"StepRemoteAction", &model.Step{Uses: "remote/action@v1"}, &stepActionRemote{}}, + {"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}}, + {"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}}, + {"StepRun", &model.Step{Run: "cmd"}, &stepRun{}}, + {"StepBuiltinAction", &model.Step{Uses: "builtin:checkout"}, &stepActionBuiltin{}}, + } { + t.Run(test.name, func(t *testing.T) { + step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{}) + require.NoError(t, err) + assert.IsType(t, test.want, step) }) } } diff --git a/act/runner/step_run.go b/act/runner/step_run.go index 3901198d..cb062156 100644 --- a/act/runner/step_run.go +++ b/act/runner/step_run.go @@ -5,6 +5,7 @@ package runner import ( + "cmp" "context" "fmt" "io" @@ -23,8 +24,6 @@ import ( yaml "go.yaml.in/yaml/v4" ) -var builtinShells = []string{"bash", "sh", "pwsh", "powershell", "cmd", "python"} - type stepRun struct { Step *model.Step RunContext *RunContext @@ -41,14 +40,24 @@ func (sr *stepRun) pre() common.Executor { return common.NewPipelineExecutor() } func (sr *stepRun) main() common.Executor { sr.env = map[string]string{} return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor( - sr.setupShellCommandExecutor(), + func(ctx context.Context) error { + scriptName, script, err := sr.setupShellCommand(ctx) + if err != nil { + return err + } + return sr.RunContext.JobContainer.Copy(sr.RunContext.JobContainer.GetActPath(), &container.FileEntry{ + Name: scriptName, + Mode: 0o755, + Body: script, + })(ctx) + }, func(ctx context.Context) error { rc := sr.getRunContext() // Apply ::add-path:: effects before printing so PATH is accurate in the env: block. rc.ApplyExtraPath(ctx, &sr.env) sr.printRunScriptActionDetails(ctx) if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil { - return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx) + return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, sr.WorkingDirectory)(ctx) } return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx) }, @@ -86,11 +95,7 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string] } rawLogger := common.Logger(ctx).With(rawOutputField, true) - title := step.Uses - if step.Name != "" { - title = step.Name - } - rawLogger.Infof("::group::Run %s", EscapeCommandData(title)) + rawLogger.Infof("::group::Run %s", EscapeCommandData(cmp.Or(step.Name, step.Uses))) if len(step.With) > 0 { rawLogger.Infof("with:") @@ -223,30 +228,6 @@ func (sr *stepRun) getIfExpression(_ context.Context, _ stepStage) string { return sr.Step.If.Value } -func (sr *stepRun) setupShellCommandExecutor() common.Executor { - return func(ctx context.Context) error { - scriptName, script, err := sr.setupShellCommand(ctx) - if err != nil { - return err - } - - rc := sr.getRunContext() - return rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{ - Name: scriptName, - Mode: 0o755, - Body: script, - })(ctx) - } -} - -func getScriptName(rc *RunContext, step *model.Step) string { - scriptName := step.ID - for rcs := rc; rcs.Parent != nil; rcs = rcs.Parent { - scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) - } - return "workflow/" + scriptName -} - // TODO: Currently we just ignore top level keys, BUT we should return proper error on them // BUTx2 I leave this for when we rewrite act to use actionlint for workflow validation // so we return proper errors before any execution or spawning containers @@ -272,8 +253,9 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, sr.interpolatedScript = script // GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid - if slices.Contains(builtinShells, strings.ToLower(step.Shell)) { - step.Shell = strings.ToLower(step.Shell) + switch shell := strings.ToLower(step.Shell); shell { + case "bash", "sh", "pwsh", "powershell", "cmd", "python": + step.Shell = shell } scCmd := step.ShellCommand() @@ -285,7 +267,11 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, } sr.shellCommand = scCmd - name = getScriptName(sr.RunContext, step) + name = step.ID + for rc := sr.RunContext; rc.Parent != nil; rc = rc.Parent { + name = fmt.Sprintf("%s-composite-%s", rc.Parent.CurrentStep, name) + } + name = "workflow/" + name // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L47-L64 // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 @@ -330,13 +316,7 @@ func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (b if err != nil { return false, fmt.Errorf("unable to interpolate the shell: %w", err) } - if shell == "" { - shell = rc.jobRunDefaults.Shell - } - if shell == "" { - shell = rc.Run.Workflow.Defaults.Run.Shell - } - step.Shell = shell + step.Shell = cmp.Or(shell, rc.jobRunDefaults.Shell, rc.Run.Workflow.Defaults.Run.Shell) implicitShell := step.Shell == "" if implicitShell { @@ -381,13 +361,7 @@ func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEv if err != nil { return fmt.Errorf("unable to interpolate the working directory: %w", err) } - if workingdirectory == "" { - workingdirectory = rc.jobRunDefaults.WorkingDirectory - } // top level keys in workflow file like `defaults` or `env` can't hold expressions - if workingdirectory == "" { - workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory - } - sr.WorkingDirectory = workingdirectory + sr.WorkingDirectory = cmp.Or(workingdirectory, rc.jobRunDefaults.WorkingDirectory, rc.Run.Workflow.Defaults.Run.WorkingDirectory) return nil } diff --git a/act/runner/step_run_test.go b/act/runner/step_run_test.go index 2892c2dc..28c2d35d 100644 --- a/act/runner/step_run_test.go +++ b/act/runner/step_run_test.go @@ -5,7 +5,6 @@ package runner import ( - "bytes" "cmp" "context" "io" @@ -60,20 +59,8 @@ func TestStepRun(t *testing.T) { cm.On("Copy", "/var/run/act", []*container.FileEntry{fileEntry}).Return(noopExecutor) cm.On("Exec", []string{"bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "/var/run/act/workflow/1.sh"}, mock.AnythingOfType("map[string]string"), "", "workdir").Return(noopExecutor) - cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor) - - ctx := t.Context() - - cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil) - - err := sr.main()(ctx) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + expectStepFileCommands(t, cm) + require.NoError(t, sr.main()(t.Context())) cm.AssertExpectations(t) } diff --git a/act/runner/step_test.go b/act/runner/step_test.go index 5dee858a..53623b1d 100644 --- a/act/runner/step_test.go +++ b/act/runner/step_test.go @@ -15,7 +15,6 @@ import ( "testing/synctest" "gitea.com/gitea/runner/act/common" - "gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/internal/pkg/telemetry" @@ -116,11 +115,6 @@ func (sm *stepMock) getRunContext() *RunContext { return args.Get(0).(*RunContext) } -func (sm *stepMock) getGithubContext(ctx context.Context) *model.GithubContext { - args := sm.Called() - return args.Get(0).(*RunContext).getGithubContext(ctx) -} - func (sm *stepMock) getStepModel() *model.Step { args := sm.Called() return args.Get(0).(*model.Step) @@ -132,9 +126,6 @@ func (sm *stepMock) getEnv() *map[string]string { } func TestSetupEnv(t *testing.T) { - cm := &containerMock{} - sm := &stepMock{} - rc := &RunContext{ Config: &Config{ Env: map[string]string{ @@ -156,21 +147,16 @@ func TestSetupEnv(t *testing.T) { Env: map[string]string{ "RC_KEY": "rcvalue", }, - JobContainer: cm, + JobContainer: &containerMock{}, } step := &model.Step{} require.NoError(t, step.RawWith.Encode(map[string]string{"STEP_WITH": "with-value", "ID": "${{ fromJSON('1234567') }}"})) env := map[string]string{} - sm.On("getRunContext").Return(rc) - sm.On("getGithubContext").Return(rc) - sm.On("getStepModel").Return(step) - sm.On("getEnv").Return(&env) + testStep := &stepRun{RunContext: rc, Step: step, env: env} + require.NoError(t, setupEnv(t.Context(), testStep)) + require.NoError(t, setupInputs(t.Context(), testStep)) - require.NoError(t, setupEnv(t.Context(), sm)) - require.NoError(t, setupInputs(t.Context(), sm)) - - // These are commit or system specific delete(env, "GITHUB_REF") delete(env, "GITHUB_REF_NAME") delete(env, "GITHUB_REF_TYPE") @@ -179,7 +165,6 @@ func TestSetupEnv(t *testing.T) { delete(env, "GITHUB_REPOSITORY") delete(env, "GITHUB_REPOSITORY_OWNER") delete(env, "GITHUB_ACTOR") - // Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead. delete(env, "RUNNER_NAME") delete(env, "RUNNER_WORKSPACE") @@ -211,8 +196,6 @@ func TestSetupEnv(t *testing.T) { "RUNNER_TRACKING_ID": "", }, env) - cm.AssertExpectations(t) - for _, expression := range []string{"inputs.args", "env.ARGS"} { t.Run("deferred inputs from "+expression, func(t *testing.T) { workflow, err := model.ReadWorkflow(strings.NewReader(` @@ -276,169 +259,96 @@ jobs: } func TestIsStepEnabled(t *testing.T) { - createTestStep := func(t *testing.T, input string) step { - var step *model.Step - err := yaml.Unmarshal([]byte(input), &step) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - return &stepRun{ - RunContext: &RunContext{ - Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }}, - StepResults: map[string]*model.StepResult{}, - Env: map[string]string{}, - Run: &model.Run{ - JobID: "job1", - Workflow: &model.Workflow{ - Name: "workflow1", - Jobs: map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), + for _, test := range []struct { + name string + result *model.StepResult + success bool + }{ + {"no prior steps", nil, true}, + {"prior step succeeded", &model.StepResult{Conclusion: model.StepStatusSuccess}, true}, + {"prior step failed", &model.StepResult{Conclusion: model.StepStatusFailure}, false}, + } { + t.Run(test.name, func(t *testing.T) { + for _, condition := range []struct { + expression string + want bool + }{{"success()", test.success}, {"failure()", !test.success}, {"always()", true}} { + t.Run(condition.expression, func(t *testing.T) { + step := &stepRun{ + RunContext: &RunContext{ + Config: &Config{}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, + StepResults: map[string]*model.StepResult{}, }, - }, - }, + Step: &model.Step{If: yaml.Node{Value: condition.expression}}, + } + if test.result != nil { + step.RunContext.StepResults["prior"] = test.result + } + enabled, err := isStepEnabled(t.Context(), step.getIfExpression(t.Context(), stepStageMain), step, stepStageMain) + require.NoError(t, err) + assert.Equal(t, condition.want, enabled) + }) + } + }) + } + + t.Run("env and step inputs are unavailable at every stage", func(t *testing.T) { + step := &stepRun{ + RunContext: &RunContext{ + Config: &Config{}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, + Env: map[string]string{"INPUT_FORGED": "leaked"}, }, - Step: step, + Step: &model.Step{}, + env: map[string]string{"INPUT_FORGED": "leaked"}, } - } - - logging.SetLevel(logging.LevelDebug) - assertObject := assert.New(t) - - // success() - step := createTestStep(t, "if: success()") - assertObject.True(isStepEnabled(t.Context(), step.getIfExpression(t.Context(), stepStageMain), step, stepStageMain)) - - step = createTestStep(t, "if: success()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - step = createTestStep(t, "if: success()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - // failure() - step = createTestStep(t, "if: failure()") - assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - step = createTestStep(t, "if: failure()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - step = createTestStep(t, "if: failure()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - // always() - step = createTestStep(t, "if: always()") - assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - step = createTestStep(t, "if: always()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusSuccess, - } - assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - step = createTestStep(t, "if: always()") - step.getRunContext().StepResults["a"] = &model.StepResult{ - Conclusion: model.StepStatusFailure, - } - assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - - // neither env nor the step's own with: values are inputs, at any stage - step = createTestStep(t, "if: inputs.forged") - step.getRunContext().Env["INPUT_FORGED"] = "leaked" - *step.getEnv() = map[string]string{"INPUT_FORGED": "leaked"} - assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain)) - assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStagePost)) + for _, stage := range []stepStage{stepStageMain, stepStagePost} { + enabled, err := isStepEnabled(t.Context(), "inputs.forged", step, stage) + require.NoError(t, err) + assert.False(t, enabled) + } + }) } func TestIsContinueOnError(t *testing.T) { - createTestStep := func(t *testing.T, input string) step { - var step *model.Step - err := yaml.Unmarshal([]byte(input), &step) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - return &stepRun{ - RunContext: &RunContext{ - Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }}, - StepResults: map[string]*model.StepResult{}, - Env: map[string]string{}, - Run: &model.Run{ - JobID: "job1", - Workflow: &model.Workflow{ - Name: "workflow1", - Jobs: map[string]*model.Job{ - "job1": createJob(t, `runs-on: ubuntu-latest`, ""), - }, - }, + for _, test := range []struct { + name, expression string + env map[string]string + want, wantError bool + }{ + {name: "absent"}, + {name: "explicit true", expression: "true", want: true}, + {name: "explicit false", expression: "false"}, + {name: "expression true", expression: "${{ 'test' == 'test' }}", want: true}, + {name: "expression false", expression: "${{ 'test' != 'test' }}"}, + {name: "step inputs are unavailable", expression: "${{ inputs.forged }}", env: map[string]string{"INPUT_FORGED": "true"}}, + {name: "invalid expression", expression: "${{ 'test' != test }}", wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + step := &stepRun{ + RunContext: &RunContext{ + Config: &Config{}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, }, - }, - Step: step, - } + Step: &model.Step{}, + env: test.env, + } + continueOnError, err := isContinueOnError(t.Context(), test.expression, step) + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.want, continueOnError) + }) } - - logging.SetLevel(logging.LevelDebug) - assertObject := assert.New(t) - - // absent - step := createTestStep(t, "name: test") - continueOnError, err := isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.False(continueOnError) - assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act - - // explcit true - step = createTestStep(t, "continue-on-error: true") - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.True(continueOnError) - assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act - - // explicit false - step = createTestStep(t, "continue-on-error: false") - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.False(continueOnError) - assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act - - // expression true - step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}") - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.True(continueOnError) - assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act - - // expression false - step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}") - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.False(continueOnError) - assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act - - // the step's own with: values are not inputs - step = createTestStep(t, "continue-on-error: ${{ inputs.forged }}") - *step.getEnv() = map[string]string{"INPUT_FORGED": "true"} - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.False(continueOnError) - require.NoError(t, err) - - // expression parse error - step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}") - continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain) - assertObject.False(continueOnError) - assertObject.Error(err) } -// A refused ::set-env::/::add-path:: records a job-scoped error. When the step that -// produced it also fails on its own, the refusal must be cleared at the step boundary, so -// it fails only that step and never leaks onto a later step that runs anyway (if: always()). func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) { cm := &containerMock{} - noop := func(context.Context) error { return nil } - cm.On("Copy", mock.Anything, mock.Anything).Return(noop) - cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noop) + cm.On("Copy", mock.Anything, mock.Anything).Return(noopExecutor) + cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noopExecutor) rc := &RunContext{ Config: &Config{Env: map[string]string{}}, @@ -451,29 +361,21 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) { JobContainer: cm, } rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) - // Dryrun skips reading the path file back from the (mocked) container. ctx := common.WithDryrun(t.Context(), true) - // A refusal parsed out of the job container's own output belongs to no step, so the - // first step must not be failed by it. rc.commandHandler(ctx)("::set-env name=setup::y\n") stepSetup := &stepRun{RunContext: rc, Step: &model.Step{ID: "setup"}, env: map[string]string{}} - require.NoError(t, runStepExecutor(stepSetup, stepStageMain, func(context.Context) error { return nil })(ctx)) + require.NoError(t, runStepExecutor(stepSetup, stepStageMain, noopExecutor)(ctx)) - // Step A refuses a ::set-env:: and then fails on its own. stepA := &stepRun{RunContext: rc, Step: &model.Step{ID: "a"}, env: map[string]string{}} errA := runStepExecutor(stepA, stepStageMain, func(context.Context) error { rc.commandHandler(ctx)("::set-env name=x::y\n") return errors.New("boom") })(ctx) - // The step fails with its own error, not the refusal. require.ErrorContains(t, errA, "boom") - // Step B runs despite step A's failure (if: always()) and issues no unsecure command; - // it must not inherit step A's refusal. stepB := &stepRun{RunContext: rc, Step: &model.Step{ID: "b", If: yaml.Node{Value: "always()"}}, env: map[string]string{}} - errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx) - require.NoError(t, errB) + require.NoError(t, runStepExecutor(stepB, stepStageMain, noopExecutor)(ctx)) } type fileCommandTestContainer struct { diff --git a/e2e/cache_test.go b/e2e/cache_test.go index 8eaddfe0..8920fbb4 100644 --- a/e2e/cache_test.go +++ b/e2e/cache_test.go @@ -5,44 +5,23 @@ package e2e -import ( - "testing" -) +import "testing" -func testActionsCacheRoundTrip(t *testing.T) { +func testCacheAndArtifactRoundTrips(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - v2 bool + name, workflow, label string + options runnerOptions }{ - {name: "cache_v2", v2: true}, - {name: "cache_v1", v2: false}, + {"cache_v2", "cache.yml", "e2e-cache", runnerOptions{cacheV2: new(true)}}, + {"cache_v1", "cache.yml", "e2e-cache", runnerOptions{cacheV2: new(false)}}, + {"artifact_through_the_cache_server", "artifact.yml", "e2e-artifact", runnerOptions{cacheV2: new(true)}}, + {"artifact_direct_with_no_reachable_cache", "artifact.yml", "e2e-artifact", runnerOptions{cacheHost: "cache.invalid", cacheV2: new(false)}}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - api, repo, _ := startIsolatedScenario(t, "cache.yml", "e2e-cache", runnerOptions{cacheV2: &tc.v2}) - - wfRun := waitForRun(t, api, repo) - requireSuccess(t, api, repo, wfRun.ID) - }) - } -} - -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) + api, repo, _ := startIsolatedScenario(t, tc.workflow, tc.label, tc.options) wfRun := waitForRun(t, api, repo) requireSuccess(t, api, repo, wfRun.ID) diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index e160f430..60d29ec6 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -26,8 +26,7 @@ func TestCompatibility(t *testing.T) { }) t.Run("payloads", testPayloads) - t.Run("cache", testActionsCacheRoundTrip) - t.Run("artifact", testArtifactRoundTrip) + t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips) t.Run("cancellation_and_log_streaming", testRunCancellation) t.Run("dispatch", testWorkflowDispatch) t.Run("builtin_checkout", testBuiltinCheckout) diff --git a/e2e/main_test.go b/e2e/main_test.go index 3abe2956..814e3e73 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -21,10 +21,6 @@ const runTimeout = 3 * time.Minute var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) -func repoName(t *testing.T) string { - return strings.ToLower(nonRepoChars.ReplaceAllString(t.Name(), "-")) -} - var fixture *GiteaFixture var skipReason string @@ -59,7 +55,7 @@ func runSuite(m *testing.M) int { func newScenario(t *testing.T) (*GiteaAPI, string) { t.Helper() - repo := repoName(t) + repo := strings.ToLower(nonRepoChars.ReplaceAllString(t.Name(), "-")) api := &GiteaAPI{baseURL: fixture.baseURL, token: fixture.adminToken} if err := api.CreateRepo(t.Context(), repo); err != nil { t.Fatalf("create repo: %v", err) diff --git a/e2e/runner_harness.go b/e2e/runner_harness.go index 8e4e5d48..81f4b700 100644 --- a/e2e/runner_harness.go +++ b/e2e/runner_harness.go @@ -47,10 +47,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p if options.cacheV2 != nil { cfg.Cache.V2 = options.cacheV2 } - cfg.Container.DockerHost = os.Getenv("DOCKER_HOST") - if cfg.Container.DockerHost == "" { - cfg.Container.DockerHost = "unix:///var/run/docker.sock" - } + cfg.Container.DockerHost = cmp.Or(os.Getenv("DOCKER_HOST"), "unix:///var/run/docker.sock") cfg.Cache.Dir = t.TempDir() + "/cache" cfg.Cache.Host = options.cacheHost cfg.Runner.Insecure = true diff --git a/internal/app/cmd/cache-server.go b/internal/app/cmd/cache-server.go index d6b852ed..35bb7dad 100644 --- a/internal/app/cmd/cache-server.go +++ b/internal/app/cmd/cache-server.go @@ -4,6 +4,7 @@ package cmd import ( + "cmp" "errors" "fmt" @@ -33,32 +34,14 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co return errors.New("cache-server does not support cache.s3; set cache.s3 on the runners instead") } - var ( - dir = cfg.Cache.Dir - host = cfg.Cache.Host - port = cfg.Cache.Port - ) - - // cacheArgs has higher priority - if cacheArgs.Dir != "" { - dir = cacheArgs.Dir - } - if cacheArgs.Host != "" { - host = cacheArgs.Host - } - if cacheArgs.Port != 0 { - port = cacheArgs.Port - } - - secret := cfg.Cache.ExternalSecret - if secret == "" { + if cfg.Cache.ExternalSecret == "" { return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") } cacheHandler, err := artifactcache.StartHandler(artifactcache.Options{ - Dir: dir, - OutboundIP: host, - Port: port, - InternalSecret: secret, + Dir: cmp.Or(cacheArgs.Dir, cfg.Cache.Dir), + OutboundIP: cmp.Or(cacheArgs.Host, cfg.Cache.Host), + Port: cmp.Or(cacheArgs.Port, cfg.Cache.Port), + InternalSecret: cfg.Cache.ExternalSecret, Policy: run.CachePolicy(cfg), Logger: logging.Default().With("module", "cache_request"), }) diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index c2d6dbbd..163c81f3 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -163,8 +163,8 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } } - if !slices.Equal(reg.Labels, ls.ToStrings()) { - reg.Labels = ls.ToStrings() + if labelStrings := ls.ToStrings(); !slices.Equal(reg.Labels, labelStrings) { + reg.Labels = labelStrings if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil { return fmt.Errorf("failed to save runner config: %w", err) } @@ -244,26 +244,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } if daemArgs.Once || reg.Ephemeral { - done := make(chan struct{}) - go func() { - defer close(done) - poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) }) - }() - - // shutdown when we complete a job or cancel is requested - select { - case <-ctx.Done(): - case <-done: - } + go poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) }) } else { go poller.Poll() + } - // Stop either on an external cancellation or when the poller shuts - // itself down (e.g. after the runner has been unregistered). - select { - case <-ctx.Done(): - case <-poller.Done(): - } + select { + case <-ctx.Done(): + case <-poller.Done(): } logging.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Runner.Name, cfg.Runner.ShutdownTimeout) @@ -329,13 +317,8 @@ func splitLabels(s string) []string { // initLogging sets up the process-wide logger. func initLogging(cfg *config.Config) { callPrettyfier := func(f *runtime.Frame) (string, string) { - // get function name - s := strings.Split(f.Function, ".") - funcname := "[" + s[len(s)-1] + "]" - // get file name and line number _, filename := path.Split(f.File) - filename = "[" + filename + ":" + strconv.Itoa(f.Line) + "]" - return funcname, filename + return "[" + f.Function[strings.LastIndexByte(f.Function, '.')+1:] + "]", "[" + filename + ":" + strconv.Itoa(f.Line) + "]" } isTerm := term.IsTerminal(int(os.Stdout.Fd())) @@ -407,11 +390,12 @@ func getDockerSocketPath(configDockerHost string) (string, error) { } for _, p := range commonSocketPaths { - if _, err := os.Lstat(os.ExpandEnv(p)); err == nil { + socket := os.ExpandEnv(p) + if _, err := os.Lstat(socket); err == nil { if strings.HasPrefix(p, `\\.\`) { - return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), nil + return "npipe://" + filepath.ToSlash(socket), nil } - return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), nil + return "unix://" + filepath.ToSlash(socket), nil } } diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go index 3daa1dec..52989534 100644 --- a/internal/app/cmd/exec.go +++ b/internal/app/cmd/exec.go @@ -203,12 +203,8 @@ func parseKVAndFile(rawKVs []string, filePath string) map[string]string { _ = readEnvs(filePath, result) for _, raw := range rawKVs { - parts := strings.SplitN(raw, "=", 2) - if len(parts) == 2 { - result[parts[0]] = parts[1] - } else { - result[parts[0]] = "" - } + key, value, _ := strings.Cut(raw, "=") + result[key] = value } return result } @@ -265,39 +261,25 @@ func printList(plan *model.Plan) { for i, stage := range plan.Stages { for _, r := range stage.Runs { - jobID := r.JobID line := lineInfoDef{ - jobID: jobID, + jobID: r.JobID, jobName: r.String(), stage: strconv.Itoa(i), wfName: r.Workflow.Name, wfFile: r.Workflow.File, events: strings.Join(r.Workflow.On(), `,`), } - if _, ok := jobs[jobID]; ok { + if jobs[r.JobID] { duplicateJobIDs = true - } else { - jobs[jobID] = true } + jobs[r.JobID] = true lineInfos = append(lineInfos, line) - if jobIDMaxWidth < len(line.jobID) { - jobIDMaxWidth = len(line.jobID) - } - if jobNameMaxWidth < len(line.jobName) { - jobNameMaxWidth = len(line.jobName) - } - if stageMaxWidth < len(line.stage) { - stageMaxWidth = len(line.stage) - } - if wfNameMaxWidth < len(line.wfName) { - wfNameMaxWidth = len(line.wfName) - } - if wfFileMaxWidth < len(line.wfFile) { - wfFileMaxWidth = len(line.wfFile) - } - if eventsMaxWidth < len(line.events) { - eventsMaxWidth = len(line.events) - } + jobIDMaxWidth = max(jobIDMaxWidth, len(line.jobID)) + jobNameMaxWidth = max(jobNameMaxWidth, len(line.jobName)) + stageMaxWidth = max(stageMaxWidth, len(line.stage)) + wfNameMaxWidth = max(wfNameMaxWidth, len(line.wfName)) + wfFileMaxWidth = max(wfFileMaxWidth, len(line.wfFile)) + eventsMaxWidth = max(eventsMaxWidth, len(line.events)) } } @@ -356,21 +338,15 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error { case execArgs.job != "": logging.Infof("Preparing plan with a job: %s", execArgs.job) filterPlan, err = planner.PlanJob(execArgs.job) - if err != nil { - return err - } case filterEventName != "": logging.Infof("Preparing plan for a event: %s", filterEventName) filterPlan, err = planner.PlanEvent(filterEventName) - if err != nil { - return err - } default: logging.Infof("Preparing plan with all jobs") filterPlan, err = planner.PlanAll() - if err != nil { - return err - } + } + if err != nil { + return err } printList(filterPlan) @@ -385,32 +361,31 @@ func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]st } return &runner.Config{ - Workdir: i.Workdir(), - BindWorkdir: false, - ForcePull: i.forcePull, - ForceRebuild: i.forceRebuild, - JSONLogger: i.jsonLogger, - Env: env, - ProxyEnv: proxyEnv, - Vars: i.LoadVars(), - Secrets: i.LoadSecrets(), - InsecureSecrets: i.insecureSecrets, - Privileged: i.privileged, - UsernsMode: i.usernsMode, - ContainerArchitecture: i.containerArchitecture, - ContainerDaemonSocket: i.containerDaemonSocket, - UseGitIgnore: i.useGitIgnore, - GitHubInstance: i.githubInstance, - ContainerCapAdd: i.containerCapAdd, - ContainerCapDrop: i.containerCapDrop, - ContainerOptions: i.containerOptions, - ArtifactServerPath: i.artifactServerPath, - ArtifactServerPort: i.artifactServerPort, - ArtifactServerAddr: i.artifactServerAddr, - NoSkipCheckout: i.noSkipCheckout, - EventName: eventName, - EventJSON: eventJSON, - // PresetGitHubContext: preset, + Workdir: i.Workdir(), + BindWorkdir: false, + ForcePull: i.forcePull, + ForceRebuild: i.forceRebuild, + JSONLogger: i.jsonLogger, + Env: env, + ProxyEnv: proxyEnv, + Vars: i.LoadVars(), + Secrets: i.LoadSecrets(), + InsecureSecrets: i.insecureSecrets, + Privileged: i.privileged, + UsernsMode: i.usernsMode, + ContainerArchitecture: i.containerArchitecture, + ContainerDaemonSocket: i.containerDaemonSocket, + UseGitIgnore: i.useGitIgnore, + GitHubInstance: i.githubInstance, + ContainerCapAdd: i.containerCapAdd, + ContainerCapDrop: i.containerCapDrop, + ContainerOptions: i.containerOptions, + ArtifactServerPath: i.artifactServerPath, + ArtifactServerPort: i.artifactServerPort, + ArtifactServerAddr: i.artifactServerAddr, + NoSkipCheckout: i.noSkipCheckout, + EventName: eventName, + EventJSON: eventJSON, ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName, ContainerMaxLifetime: maxLifetime, ContainerNetworkMode: container.NetworkMode(i.network), @@ -465,15 +440,12 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command if execArgs.job != "" { logging.Infof("Planning job: %s", execArgs.job) plan, err = planner.PlanJob(execArgs.job) - if err != nil { - return err - } } else { logging.Infof("Planning jobs for event: %s", eventName) plan, err = planner.PlanEvent(eventName) - if err != nil { - return err - } + } + if err != nil { + return err } maxLifetime := 3 * time.Hour @@ -548,11 +520,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command config.Env["ACT_EXEC"] = "true" config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken - if t := config.Secrets["GITEA_TOKEN"]; t != "" { - config.Token = t - } else if t := config.Secrets["GITHUB_TOKEN"]; t != "" { - config.Token = t - } + config.Token = cmp.Or(config.Secrets["GITEA_TOKEN"], config.Secrets["GITHUB_TOKEN"]) if !execArgs.debug { logLevel := logging.LevelInfo diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index 3cce9394..3a6c3961 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -117,10 +117,7 @@ func (r *registerInputs) validate() error { if r.Token == "" { return errors.New("token is empty") } - if len(r.Labels) > 0 { - return validateLabels(r.Labels) - } - return nil + return validateLabels(r.Labels) } func validateLabels(ls []string) error { @@ -141,20 +138,14 @@ func (r *registerInputs) stageValue(stage registerStage) string { case StageInputRunnerName: return r.RunnerName case StageInputLabels: - if len(r.Labels) > 0 { - return strings.Join(r.Labels, ",") - } + return strings.Join(r.Labels, ",") } return "" } func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *config.Config) registerStage { - // must set instance address and token. - // if empty, keep current stage. - if stage == StageInputInstance || stage == StageInputToken { - if value == "" { - return stage - } + if (stage == StageInputInstance || stage == StageInputToken) && value == "" { + return stage } // set hostname for runner name if empty @@ -222,11 +213,10 @@ func initInputs(regArgs *registerArgs) (*registerInputs, error) { case regArgs.Token != "": token = regArgs.Token default: - envToken, ok := os.LookupEnv(registerTokenEnvVar) - if !ok || envToken == "" { + token = os.Getenv(registerTokenEnvVar) + if token == "" { return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar) } - token = envToken } inputs := ®isterInputs{ InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr), diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index f2a96f2e..fc940cd2 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -65,7 +65,6 @@ func TestRegisterInputsValidate(t *testing.T) { func TestValidateLabels(t *testing.T) { require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"})) - // a colon that is not a supported schema is part of the label name require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"})) require.Error(t, validateLabels([]string{"ubuntu:host", ""})) } @@ -149,97 +148,63 @@ func TestRegisterInputsAssignToNext(t *testing.T) { } func TestInitInputs(t *testing.T) { - t.Run("missing token", func(t *testing.T) { - _, err := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", + for _, tc := range []struct { + name, tokenFile, wantErr string + env *string + }{ + {name: "missing token", wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"}, + {name: "empty token", env: new(""), wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"}, + { + name: "invalid token file", tokenFile: "/tmp/nonexistent", env: new("from-env"), + wantErr: "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.env != nil { + t.Setenv(registerTokenEnvVar, *tc.env) + } + _, err := initInputs(®isterArgs{ + InstanceAddr: "http://localhost:3000", + TokenFile: tc.tokenFile, + RunnerName: "runner", + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.EqualError(t, err, tc.wantErr) }) - require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable") - }) + } - t.Run("empty token", func(t *testing.T) { - t.Setenv(registerTokenEnvVar, "") - _, err := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - Token: "", - TokenFile: "", - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", + for _, tc := range []struct { + name, address, token, runnerName, wantToken string + file bool + }{ + {name: "valid token", address: " http://localhost:3000 ", token: "from-plain-arg\n", runnerName: "runner\n", wantToken: "from-plain-arg"}, + {name: "valid token file", address: "http://localhost:3000", runnerName: "runner", file: true, wantToken: "from-file"}, + {name: "token from environment variable", address: "http://localhost:3000", runnerName: "runner", wantToken: "from-env"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(registerTokenEnvVar, "from-env") + var tokenFile string + if tc.file { + tokenFile = filepath.Join(t.TempDir(), "from-file") + require.NoError(t, os.WriteFile(tokenFile, []byte("from-file\n"), 0o600)) + } + inputs, err := initInputs(®isterArgs{ + InstanceAddr: tc.address, + Token: tc.token, + TokenFile: tokenFile, + RunnerName: tc.runnerName, + Ephemeral: true, + Labels: " ubuntu:host , ubuntu:docker://node:18 ", + }) + require.NoError(t, err) + require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) + require.Equal(t, tc.wantToken, inputs.Token) + require.Equal(t, "runner", inputs.RunnerName) + require.True(t, inputs.Ephemeral) + require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) }) - require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable") - }) - - t.Run("invalid token file", func(t *testing.T) { - t.Setenv(registerTokenEnvVar, "from-env") - _, err := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - TokenFile: "/tmp/nonexistent", - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", - }) - require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory") - }) - - t.Run("valid token", func(t *testing.T) { - t.Setenv(registerTokenEnvVar, "from-env") - inputs, err := initInputs(®isterArgs{ - InstanceAddr: " http://localhost:3000 ", - Token: "from-plain-arg\n", - RunnerName: "runner\n", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", - }) - require.NoError(t, err) - require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) - require.Equal(t, "from-plain-arg", inputs.Token) - require.Equal(t, "runner", inputs.RunnerName) - require.True(t, inputs.Ephemeral) - require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) - }) - - t.Run("valid token file", func(t *testing.T) { - t.Setenv(registerTokenEnvVar, "from-env") - tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file") - require.NoError(t, createErr) - defer tokenFile.Close() - _, writeErr := tokenFile.WriteString("from-file\n") - require.NoError(t, writeErr) - _ = tokenFile.Sync() - - inputs, err := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - TokenFile: tokenFile.Name(), - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", - }) - require.NoError(t, err) - require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) - require.Equal(t, "from-file", inputs.Token) - require.Equal(t, "runner", inputs.RunnerName) - require.True(t, inputs.Ephemeral) - require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) - }) - - t.Run("token from environment variable", func(t *testing.T) { - t.Setenv(registerTokenEnvVar, "from-env") - inputs, err := initInputs(®isterArgs{ - InstanceAddr: "http://localhost:3000", - RunnerName: "runner", - Ephemeral: true, - Labels: " ubuntu:host , ubuntu:docker://node:18 ", - }) - require.NoError(t, err) - require.Equal(t, "http://localhost:3000", inputs.InstanceAddr) - require.Equal(t, "from-env", inputs.Token) - require.Equal(t, "runner", inputs.RunnerName) - require.True(t, inputs.Ephemeral) - require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels) - }) + } t.Run("empty labels", func(t *testing.T) { inputs, _ := initInputs(®isterArgs{ diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index 8280fc48..1c321e31 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -83,8 +83,6 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller { jobsCtx, shutdownJobs := context.WithCancel(context.Background()) - done := make(chan struct{}) - p := &Poller{ client: client, runner: runner, @@ -96,11 +94,11 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller { jobsCtx: jobsCtx, shutdownJobs: shutdownJobs, - done: done, + done: make(chan struct{}), + availabilityReady: true, + availabilityReason: "ok", } p.lastHealthyPoll.Store(time.Now().UnixNano()) - p.availabilityReady = true - p.availabilityReason = "ok" return p } @@ -132,8 +130,8 @@ func (p *Poller) Poll() { continue } - task, ok := p.fetchTask(p.pollingCtx, s) - if !ok { + task := p.fetchTask(p.pollingCtx, s) + if task == nil { p.runIdleMaintenance() <-sem if !p.waitBackoff(s) { @@ -166,8 +164,8 @@ func (p *Poller) PollOnce(onTaskFetched func()) { } continue } - task, ok := p.fetchTask(p.pollingCtx, s) - if !ok { + task := p.fetchTask(p.pollingCtx, s) + if task == nil { p.runIdleMaintenance() if !p.waitBackoff(s) { return @@ -348,7 +346,7 @@ func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) { } } -func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, bool) { +func (p *Poller) fetchTask(ctx context.Context, s *workerState) *runnerv1.Task { reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout) defer cancel() @@ -370,7 +368,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, } s.consecutiveEmpty++ metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc() - return nil, false + return nil } s.fetchTimedOut = false metrics.PollFetchDuration.Observe(time.Since(start).Seconds()) @@ -385,22 +383,17 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, p.unregistered.Store(true) p.registrationRejected.Store(rejected) p.shutdownPolling() - return nil, false - } - // Not a long poll, so a deadline can mean the server assigned a task - // this runner never received. - if errors.Is(err, context.DeadlineExceeded) { - logging.WithError(err).Errorf("fetching a task timed out after %s", p.cfg.Runner.FetchTimeout) - } else { - logging.WithError(err).Error("failed to fetch task") + return nil } + logging.WithError(err).Error("failed to fetch task") p.lastPollFailed.Store(true) s.consecutiveErrors++ metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc() metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask, connect.CodeOf(err).String()).Inc() - return nil, false + return nil } - p.markHealthyPoll() + p.lastHealthyPoll.Store(time.Now().UnixNano()) + p.lastPollFailed.Store(false) // Successful response — reset error counter. s.consecutiveErrors = 0 @@ -408,7 +401,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, if resp == nil { s.consecutiveEmpty++ metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc() - return nil, false + return nil } if resp.TasksVersion > v { @@ -418,17 +411,12 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, if resp.Task == nil { s.consecutiveEmpty++ metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc() - return nil, false + return nil } // got a task, set `tasksVersion` to zero to force query db in next request. p.tasksVersion.CompareAndSwap(resp.TasksVersion, 0) metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc() - return resp.Task, true -} - -func (p *Poller) markHealthyPoll() { - p.lastHealthyPoll.Store(time.Now().UnixNano()) - p.lastPollFailed.Store(false) + return resp.Task } diff --git a/internal/app/poll/poller_test.go b/internal/app/poll/poller_test.go index b82590eb..83ba0fdf 100644 --- a/internal/app/poll/poller_test.go +++ b/internal/app/poll/poller_test.go @@ -22,14 +22,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestPoller_WorkerStateCounters verifies that workerState correctly tracks -// consecutive empty responses independently per state instance, and that -// fetchTask increments only the relevant counter. func TestPoller_WorkerStateCounters(t *testing.T) { client := mocks.NewClient(t) client.On("FetchTask", mock.Anything, mock.Anything).Return( func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) { - // Always return an empty response. return &runnerv1.FetchTaskResponse{}, nil }, ) @@ -42,24 +38,17 @@ func TestPoller_WorkerStateCounters(t *testing.T) { s1 := &workerState{} s2 := &workerState{} - // Each worker independently observes one empty response. - _, ok := p.fetchTask(ctx, s1) - require.False(t, ok) - _, ok = p.fetchTask(ctx, s2) - require.False(t, ok) + require.Nil(t, p.fetchTask(ctx, s1)) + require.Nil(t, p.fetchTask(ctx, s2)) assert.Equal(t, int64(1), s1.consecutiveEmpty, "worker 1 should only count its own empty response") assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2 should only count its own empty response") - // Worker 1 sees a second empty; worker 2 stays at 1. - _, ok = p.fetchTask(ctx, s1) - require.False(t, ok) + require.Nil(t, p.fetchTask(ctx, s1)) assert.Equal(t, int64(2), s1.consecutiveEmpty) assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2's counter must not be affected by worker 1's empty fetches") } -// TestPoller_FetchErrorIncrementsErrorsOnly verifies that a fetch error -// increments only the per-worker error counter, not the empty counter. func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) { client := mocks.NewClient(t) client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, errors.New("network unreachable")) @@ -69,15 +58,11 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) { p := &Poller{client: client, cfg: cfg} s := &workerState{} - _, ok := p.fetchTask(t.Context(), s) - require.False(t, ok) + require.Nil(t, p.fetchTask(t.Context(), s)) assert.Equal(t, int64(1), s.consecutiveErrors) assert.Equal(t, int64(0), s.consecutiveEmpty) } -// A deadline is the idle path once the server holds the request open, and a -// symptom before then, so it must neither reset the error count nor assert that -// the server is reachable. func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) { client := mocks.NewClient(t) client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, context.DeadlineExceeded) @@ -90,20 +75,15 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) { hook := logtest.SetDefault(t) s := &workerState{consecutiveErrors: 2} - _, ok := p.fetchTask(t.Context(), s) - require.False(t, ok) + require.Nil(t, p.fetchTask(t.Context(), s)) assert.Equal(t, int64(2), s.consecutiveErrors) assert.Equal(t, int64(1), s.consecutiveEmpty) assert.True(t, p.lastPollFailed.Load(), "a timeout must not clear a known failure") - // An idle runner against a server holding the request open must not flood. - _, _ = p.fetchTask(t.Context(), s) + p.fetchTask(t.Context(), s) assert.Len(t, hook.AllRecords(), 1) } -// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated -// response marks the runner as unregistered and cancels the polling context so -// the daemon can exit instead of retrying forever. func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) { for name, tc := range map[string]struct { fetchErr error @@ -120,8 +100,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) { require.NoError(t, err) p := New(cfg, client, nil) s := &workerState{} - _, ok := p.fetchTask(t.Context(), s) - require.False(t, ok) + require.Nil(t, p.fetchTask(t.Context(), s)) assert.True(t, p.Unregistered(), "runner should be marked unregistered") assert.Equal(t, tc.rejected, p.RegistrationRejected()) @@ -135,8 +114,6 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) { } } -// TestPoller_CalculateInterval verifies the exponential backoff math is -// correctly driven by the workerState counters. func TestPoller_CalculateInterval(t *testing.T) { cfg, err := config.LoadDefault("") require.NoError(t, err) @@ -165,7 +142,6 @@ func TestPoller_CalculateInterval(t *testing.T) { } } -// atomicMax atomically updates target to max(target, val). func atomicMax(target *atomic.Int64, val int64) { for { old := target.Load() @@ -205,7 +181,6 @@ func TestPollerReady(t *testing.T) { require.NoError(t, err) poller := New(cfg, nil, &availabilityRunner{}) - // /readyz reuses the availability the poll loop last recorded. ready, reason := poller.Ready(time.Second) assert.True(t, ready) assert.Equal(t, "ok", reason) @@ -291,63 +266,45 @@ func (m *idleAwareRunner) OnIdle(_ context.Context) { m.idleCalls.Add(1) } -func TestPollerPollCallsOnIdle(t *testing.T) { - cli := mocks.NewClient(t) - cli.On("FetchTask", mock.Anything, mock.Anything).Return( - func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) { - return &runnerv1.FetchTaskResponse{}, nil - }, - ) +func TestPollerCallsOnIdle(t *testing.T) { + for _, pollOnce := range []bool{false, true} { + name := "Poll" + if pollOnce { + name = "PollOnce" + } + t.Run(name, func(t *testing.T) { + cli := mocks.NewClient(t) + cli.On("FetchTask", mock.Anything, mock.Anything).Return(&runnerv1.FetchTaskResponse{}, nil) - cfg, err := config.LoadDefault("") - require.NoError(t, err) - cfg.Runner.Capacity = 1 - cfg.Runner.FetchInterval = 10 * time.Millisecond - cfg.Runner.FetchIntervalMax = 10 * time.Millisecond + cfg, err := config.LoadDefault("") + require.NoError(t, err) + if !pollOnce { + cfg.Runner.Capacity = 1 + } + cfg.Runner.FetchInterval = 10 * time.Millisecond + cfg.Runner.FetchIntervalMax = 10 * time.Millisecond - runner := &idleAwareRunner{} - poller := New(cfg, cli, runner) + runner := &idleAwareRunner{} + poller := New(cfg, cli, runner) + var wg sync.WaitGroup + wg.Go(func() { + if pollOnce { + poller.PollOnce(func() {}) + } else { + poller.Poll() + } + }) - var wg sync.WaitGroup - wg.Go(poller.Poll) + require.Eventually(t, func() bool { + return runner.idleCalls.Load() > 0 + }, time.Second, 10*time.Millisecond) - require.Eventually(t, func() bool { - return runner.idleCalls.Load() > 0 - }, time.Second, 10*time.Millisecond) - - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - require.NoError(t, poller.Shutdown(ctx)) - wg.Wait() -} - -func TestPollerPollOnceCallsOnIdle(t *testing.T) { - cli := mocks.NewClient(t) - cli.On("FetchTask", mock.Anything, mock.Anything).Return( - func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) { - return &runnerv1.FetchTaskResponse{}, nil - }, - ) - - cfg, err := config.LoadDefault("") - require.NoError(t, err) - cfg.Runner.FetchInterval = 10 * time.Millisecond - cfg.Runner.FetchIntervalMax = 10 * time.Millisecond - - runner := &idleAwareRunner{} - poller := New(cfg, cli, runner) - - var wg sync.WaitGroup - wg.Go(func() { poller.PollOnce(func() {}) }) - - require.Eventually(t, func() bool { - return runner.idleCalls.Load() > 0 - }, time.Second, 10*time.Millisecond) - - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - require.NoError(t, poller.Shutdown(ctx)) - wg.Wait() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.NoError(t, poller.Shutdown(ctx)) + wg.Wait() + }) + } } func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) { @@ -362,9 +319,6 @@ func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) { assert.Equal(t, int64(1), runner.totalCompleted.Load()) } -// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and -// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is -// never called concurrently (single poller). func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) { const ( capacity = 3 @@ -427,13 +381,6 @@ func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) { "all tasks should have been executed") } -// TestPoller_ShutdownForcesJobsOnTimeout locks in the fix for a -// pre-existing bug where Shutdown's timeout branch used a blocking -// `<-p.done` receive, leaving p.shutdownJobs() unreachable. With a -// task parked on jobsCtx and a Shutdown deadline shorter than the -// task's natural completion, Shutdown must force-cancel via -// shutdownJobs() and return ctx.Err() promptly — not block until the -// task would have finished on its own. func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) { var served atomic.Bool cli := mocks.NewClient(t) @@ -448,8 +395,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) { }, ) - // delay >> Shutdown timeout: Run only returns when jobsCtx is - // cancelled by shutdownJobs(). runner := &mockRunner{delay: 30 * time.Second} cfg, err := config.LoadDefault("") @@ -474,9 +419,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) { elapsed := time.Since(start) require.ErrorIs(t, err, context.DeadlineExceeded) - // With the fix, Shutdown returns shortly after the deadline once - // the forced job unwinds. Without the fix, the blocking <-p.done - // would hang for the full 30s mockRunner delay. assert.Less(t, elapsed, 5*time.Second, "Shutdown must not block on the parked task; shutdownJobs() must run on timeout") diff --git a/internal/app/run/health_check.go b/internal/app/run/health_check.go index 438abd97..8501fcee 100644 --- a/internal/app/run/health_check.go +++ b/internal/app/run/health_check.go @@ -33,7 +33,7 @@ func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) { } env := processEnvironment() - maps.Copy(env, r.cloneEnvs()) + maps.Copy(env, r.envs) env["GITEA_RUNNER_HEALTH_CHECK"] = "true" env["GITEA_RUNNER_NAME"] = r.name if r.client != nil { @@ -84,9 +84,6 @@ func executeHealthCheck(ctx context.Context, script string, timeout time.Duratio if errors.Is(checkCtx.Err(), context.DeadlineExceeded) { return fmt.Errorf("timed out after %s", timeout) } - if err == nil { - return nil - } if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { return fmt.Errorf("exited with code %d", exitErr.ExitCode()) } diff --git a/internal/app/run/post_task_script.go b/internal/app/run/post_task_script.go index 60177f8b..1bf1f45f 100644 --- a/internal/app/run/post_task_script.go +++ b/internal/app/run/post_task_script.go @@ -6,7 +6,6 @@ package run import ( "context" "errors" - "fmt" "io" "os/exec" "strconv" @@ -69,15 +68,9 @@ func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporte // copiers have finished (cmd.Wait, bounded by WaitDelay above, guarantees it). common.FlushWriter(stdout) common.FlushWriter(stderr) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err) - return - } - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { - logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode()) - return - } + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode()) + } else if err != nil { logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err) } } @@ -111,8 +104,8 @@ func (r *Runner) postTaskScriptEnv(reporter *report.Reporter, task *runnerv1.Tas func envListFromMap(env map[string]string) []string { envList := make([]string, 0, len(env)) - for k, v := range env { - envList = append(envList, fmt.Sprintf("%s=%s", k, v)) + for key, value := range env { + envList = append(envList, key+"="+value) } return envList } diff --git a/internal/app/run/proxy.go b/internal/app/run/proxy.go index ea0dca90..c010624c 100644 --- a/internal/app/run/proxy.go +++ b/internal/app/run/proxy.go @@ -120,7 +120,7 @@ func proxyPasswords() []string { var passwords []string for _, raw := range []string{cfg.HTTPProxy, cfg.HTTPSProxy} { parsed, err := url.Parse(raw) - if err != nil || parsed.User == nil { + if err != nil { continue } if password, ok := parsed.User.Password(); ok && password != "" && !slices.Contains(passwords, password) { @@ -152,11 +152,8 @@ func appendNoProxy(noProxy string, hosts ...string) string { // hostOf returns the host of a URL without its port, the form a no_proxy entry takes. It is // empty for anything without a network host, such as a unix socket. func hostOf(raw string) string { - if raw == "" { - return "" - } parsed, err := url.Parse(strings.TrimSuffix(raw, "/")) - if err != nil || parsed.Hostname() == "" { + if err != nil { return "" } return parsed.Hostname() diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index f5a59bbb..6258f102 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -99,7 +99,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) envs := make(map[string]string, len(cfg.Runner.Envs)) maps.Copy(envs, cfg.Runner.Envs) var cacheHandler *artifactcache.Handler - if cacheEnabled(cfg) { + if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled { warnIgnoredCachePolicy(cfg) warnIgnoredCacheSecret(cfg) handler, err := artifactcache.StartHandler(artifactcache.Options{ @@ -121,8 +121,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) } // set artifact gitea api - artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/" - envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI + envs["ACTIONS_RUNTIME_URL"] = strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/" envs["ACTIONS_RESULTS_URL"] = strings.TrimSuffix(cli.Address(), "/") // Set specific environments to distinguish between Gitea and GitHub @@ -169,9 +168,7 @@ func (r *Runner) OnIdle(ctx context.Context) { } // Bind-workdir mode: reclaim stale per-task workspace dirs (numeric task IDs). if r.cfg.Container.BindWorkdir { - workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/") - workdirRoot := filepath.FromSlash("/" + workdirParent) - r.cleanupStaleDirs(ctx, workdirRoot, isTaskIDDir) + r.cleanupStaleDirs(ctx, filepath.FromSlash("/"+strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")), isTaskIDDir) } // Host mode: reclaim per-job scratch dirs left behind when HostEnvironment // cleanup timed out (e.g. a delete stalled by an AV/EDR filter driver). They @@ -270,10 +267,7 @@ func (r *Runner) cleanupStaleDirs(ctx context.Context, root string, match func(n if err := ctx.Err(); err != nil { return } - if !entry.IsDir() { - continue - } - if !match(entry.Name()) { + if !entry.IsDir() || !match(entry.Name()) { continue } info, err := entry.Info() @@ -452,7 +446,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. }() workflowCallInputs := restoreCalledWorkflowContext(task) - r.reportSetup(reporter, task) + for _, line := range r.setupLines(task) { + reporter.Logf("%s", line) + } workflow, jobID, err := generateWorkflow(task) if err != nil { @@ -511,11 +507,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. telemetry.AddJobEnv(ctx, envs) preset := model.GithubContextFromMap(task.Context.AsMap()) - if t := task.Secrets["GITEA_TOKEN"]; t != "" { - preset.Token = t - } else if t := task.Secrets["GITHUB_TOKEN"]; t != "" { - preset.Token = t - } + preset.Token = cmp.Or(task.Secrets["GITEA_TOKEN"], task.Secrets["GITHUB_TOKEN"], preset.Token) if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" { envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL @@ -589,7 +581,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. ForcePull: r.cfg.Container.ForcePull, ForceRebuild: r.cfg.Container.ForceRebuild, - JSONLogger: false, Env: envs, ProxyEnv: proxyEnv, Secrets: task.Secrets, @@ -672,18 +663,8 @@ func (r *Runner) builtInCacheURL() string { return r.envs["ACTIONS_CACHE_URL"] } -func cacheEnabled(cfg *config.Config) bool { - return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled -} - -// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2 -// turns off: the bundle edit that reaches it is what the artifact actions need too. -func (r *Runner) cacheServiceV2() bool { - return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2 -} - func (r *Runner) setResultsService(envs map[string]string, resultsURL string) { - v2 := resultsURL != "" && r.cacheServiceV2() + v2 := resultsURL != "" && (r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2) // all cache.v2 turns off, the bundle edit reaching it is what the artifact actions need too if v2 { envs[runner.CacheServiceV2Env] = "true" } else { @@ -902,11 +883,7 @@ func warnIgnoredCachePolicy(cfg *config.Config) { // warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server. func warnIgnoredCacheSecret(cfg *config.Config) { - if cfg.Cache.ExternalServer != "" { - return - } - // Not using an external cache server, so any configured secret is ignored. - if cfg.Cache.ExternalSecret == "" { + if cfg.Cache.ExternalServer != "" || cfg.Cache.ExternalSecret == "" { return } // LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote. diff --git a/internal/app/run/runner_cache_test.go b/internal/app/run/runner_cache_test.go index 74aca3f3..4688f0e4 100644 --- a/internal/app/run/runner_cache_test.go +++ b/internal/app/run/runner_cache_test.go @@ -4,7 +4,6 @@ package run import ( - "bytes" "encoding/json/v2" "fmt" "io" @@ -22,53 +21,34 @@ import ( "github.com/stretchr/testify/require" ) -func emptyCfg() *config.Config { return &config.Config{} } - func TestRunner_registerCacheForTask_NoOps(t *testing.T) { for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} { - r := &Runner{cfg: emptyCfg(), cacheHandler: handler} + r := &Runner{cacheHandler: handler} unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil) require.NotNil(t, unregister, token) unregister() } } -// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit -// /find, no auth on the signed archiveLocation download. func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) { dir := filepath.Join(t.TempDir(), "artifactcache") handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"}) require.NoError(t, err) defer handler.Close() - r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}} + r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}} const publicURL = "http://a1b2c3d4e5f6:8088" token := "full-flow-token" unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil) assert.Equal(t, publicURL, resultsURL) base := handler.ExternalURL() + "/_apis/artifactcache" - do := func(method, url, contentType, contentRange, body string) *http.Response { - req, err := http.NewRequest(method, url, strings.NewReader(body)) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+token) - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if contentRange != "" { - req.Header.Set("Content-Range", contentRange) - } - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - return resp - } key := "toolkit-flow" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" body := `hello-cache-body` - // reserve - resp := do(http.MethodPost, base+"/caches", "application/json", "", + resp := cacheRequest(t, token, http.MethodPost, base+"/caches", "application/json", "", fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body))) require.Equal(t, http.StatusOK, resp.StatusCode) var reserved struct { @@ -77,19 +57,16 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) { require.NoError(t, decodeJSON(resp, &reserved)) require.NotZero(t, reserved.CacheID) - // upload - resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), + resp = cacheRequest(t, token, http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body) require.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() - // commit - resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "") + resp = cacheRequest(t, token, http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "") require.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() - // find — @actions/cache always sends comma-separated keys here - resp = do(http.MethodGet, + resp = cacheRequest(t, token, http.MethodGet, fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "") require.Equal(t, http.StatusOK, resp.StatusCode) var hit struct { @@ -100,8 +77,6 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) { require.Equal(t, key, hit.CacheKey) require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation) - // download — toolkit does NOT attach Authorization here; the signature - // in the URL must be enough. dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1)) require.NoError(t, err) defer dl.Body.Close() @@ -111,18 +86,32 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) { assert.Equal(t, body, string(got[:n])) unregister() - resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "") + resp = cacheRequest(t, token, http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "") resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } +func cacheRequest(t *testing.T, token, method, url, contentType, contentRange, body string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, url, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if contentRange != "" { + req.Header.Set("Content-Range", contentRange) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + func decodeJSON(resp *http.Response, v any) error { defer resp.Body.Close() return json.UnmarshalRead(resp.Body, v) } -// End-to-end through a runner to a remote cache-server: token unknown → 401, register → -// reserve/upload/commit/find/download all OK, revoke → 401 again. func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) { const secret = "shared-secret-for-tests" remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret}) @@ -154,10 +143,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) { repo := "owner/repoX" base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache" probe := func() int { - req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil) - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) + resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "") resp.Body.Close() return resp.StatusCode } @@ -181,15 +167,9 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) { require.NoError(t, decodeJSON(forwarded, &artifactResult)) assert.True(t, artifactResult["ok"]) - // Full reserve→upload→commit→find→download cycle, identical to what - // @actions/cache does, through the runner to the remote server. body := []byte("payload-from-task") reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))}) - req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody)) - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) + resp := cacheRequest(t, token, http.MethodPost, base+"/caches", "application/json", "", string(reserveBody)) require.Equal(t, http.StatusOK, resp.StatusCode) var reserved struct { CacheID uint64 `json:"cacheId"` @@ -197,25 +177,16 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) { require.NoError(t, decodeJSON(resp, &reserved)) require.NotZero(t, reserved.CacheID) - req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1)) - resp, err = http.DefaultClient.Do(req) - require.NoError(t, err) + resp = cacheRequest(t, token, http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), + "", fmt.Sprintf("bytes 0-%d/*", len(body)-1), string(body)) resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil) - req.Header.Set("Authorization", "Bearer "+token) - resp, err = http.DefaultClient.Do(req) - require.NoError(t, err) + resp = cacheRequest(t, token, http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "") resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) - req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil) - req.Header.Set("Authorization", "Bearer "+token) - resp, err = http.DefaultClient.Do(req) - require.NoError(t, err) + resp = cacheRequest(t, token, http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "") require.Equal(t, http.StatusOK, resp.StatusCode) var hit struct { ArchiveLocation string `json:"archiveLocation"` diff --git a/internal/app/run/runner_env_test.go b/internal/app/run/runner_env_test.go index 27d9c8fc..46c1040f 100644 --- a/internal/app/run/runner_env_test.go +++ b/internal/app/run/runner_env_test.go @@ -8,31 +8,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestRunnerCloneEnvsReturnsTaskLocalCopy(t *testing.T) { - r := &Runner{ - envs: map[string]string{ - "ACTIONS_CACHE_URL": "http://cache.example", - "ACTIONS_RUNTIME_URL": "http://runner.example", - }, - } - - cloned := r.cloneEnvs() - require.Equal(t, r.envs, cloned) - - cloned["ACTIONS_RUNTIME_TOKEN"] = "task-token" - cloned["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example" - - assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN") - assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL") - assert.Equal(t, "http://cache.example", r.envs["ACTIONS_CACHE_URL"]) -} - -// Regression test for #958: concurrent tasks writing task-specific env keys -// used to race on the shared r.envs map and crash the runner with -// "fatal error: concurrent map writes". Each task must mutate its own clone. func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) { r := &Runner{ envs: map[string]string{ @@ -48,6 +25,7 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) { go func() { defer wg.Done() envs := r.cloneEnvs() + assert.Equal(t, r.envs, envs) envs["ACTIONS_RUNTIME_TOKEN"] = "task-token" envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example" envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = "oidc-token" @@ -55,7 +33,8 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) { } wg.Wait() - assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN") - assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL") - assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_TOKEN") + assert.Equal(t, map[string]string{ + "ACTIONS_CACHE_URL": "http://cache.example", + "ACTIONS_RUNTIME_URL": "http://runner.example", + }, r.envs) } diff --git a/internal/app/run/setup.go b/internal/app/run/setup.go index ae117d34..3abf572e 100644 --- a/internal/app/run/setup.go +++ b/internal/app/run/setup.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" - "gitea.com/gitea/runner/internal/pkg/report" "gitea.com/gitea/runner/internal/pkg/ver" runnerv1 "gitea.dev/actionslib/runner/v1" @@ -23,14 +22,6 @@ import ( // falls back to the Go runtime alone. A var so tests can point it at a fixture. var osReleasePath = "/etc/os-release" -// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action -// downloads and the closing job name are written later, as the job starts. -func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) { - for _, line := range r.setupLines(task) { - reporter.Logf("%s", line) - } -} - // setupLines names the runner, then reports what it was asked to run and the host it runs on, each // in its own group. func (r *Runner) setupLines(task *runnerv1.Task) []string { @@ -52,8 +43,10 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string { ) lines = append(lines, inputLines(fields)...) lines = append(lines, "::group::Operating System") - lines = append(lines, osInfo()...) - return append(lines, "::endgroup::") + if name := prettyOSName(); name != "" { + lines = append(lines, name) + } + return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), "::endgroup::") } // inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job, @@ -75,15 +68,6 @@ func inputLines(fields map[string]*structpb.Value) []string { return append(lines, "::endgroup::") } -// osInfo describes the host the runner executes on. -func osInfo() []string { - lines := make([]string, 0, 2) - if name := prettyOSName(); name != "" { - lines = append(lines, name) - } - return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)) -} - // prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent. func prettyOSName() string { data, err := os.ReadFile(osReleasePath) diff --git a/internal/app/run/setup_test.go b/internal/app/run/setup_test.go index 91b841a9..86d8a598 100644 --- a/internal/app/run/setup_test.go +++ b/internal/app/run/setup_test.go @@ -96,33 +96,23 @@ func TestSetupLinesInputs(t *testing.T) { } func TestPrettyOSName(t *testing.T) { - tests := map[string]struct { + for _, tt := range []struct { + name string osRelease string want string + missing bool }{ - "quoted value": { - osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n", - want: "Ubuntu 24.04.4 LTS", - }, - "unquoted value": { - osRelease: "PRETTY_NAME=Alpine Linux v3.21\n", - want: "Alpine Linux v3.21", - }, - "no pretty name": { - osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n", - want: "", - }, - // A key that merely ends in PRETTY_NAME must not be mistaken for it. - "similar key": { - osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n", - want: "", - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { + {"quoted value", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n", "Ubuntu 24.04.4 LTS", false}, + {"unquoted value", "PRETTY_NAME=Alpine Linux v3.21\n", "Alpine Linux v3.21", false}, + {"no pretty name", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n", "", false}, + {"similar key", "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n", "", false}, + {"missing file", "", "", true}, + } { + t.Run(tt.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "os-release") - require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600)) + if !tt.missing { + require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600)) + } original := osReleasePath osReleasePath = path @@ -131,12 +121,4 @@ func TestPrettyOSName(t *testing.T) { assert.Equal(t, tt.want, prettyOSName()) }) } - - t.Run("missing file", func(t *testing.T) { - original := osReleasePath - osReleasePath = filepath.Join(t.TempDir(), "absent") - defer func() { osReleasePath = original }() - - assert.Empty(t, prettyOSName()) - }) } diff --git a/internal/app/run/workflow.go b/internal/app/run/workflow.go index af05e143..340c5950 100644 --- a/internal/app/run/workflow.go +++ b/internal/app/run/workflow.go @@ -6,7 +6,7 @@ package run import ( "bytes" "fmt" - "sort" + "slices" "strings" "gitea.dev/actionslib/pkg/model" @@ -26,27 +26,21 @@ func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) { } jobID := jobIDs[0] - needJobIDs := make([]string, 0, len(task.Needs)) + rawNeeds := yaml.Node{ + Kind: yaml.SequenceNode, + Content: make([]*yaml.Node, 0, len(task.Needs)), + } for id, need := range task.Needs { - needJobIDs = append(needJobIDs, id) - needJob := &model.Job{ + workflow.Jobs[id] = &model.Job{ Outputs: need.Outputs, Result: strings.ToLower(strings.TrimPrefix(need.Result.String(), "RESULT_")), } - workflow.Jobs[id] = needJob - } - sort.Strings(needJobIDs) - - rawNeeds := yaml.Node{ - Kind: yaml.SequenceNode, - Content: make([]*yaml.Node, 0, len(needJobIDs)), - } - for _, id := range needJobIDs { rawNeeds.Content = append(rawNeeds.Content, &yaml.Node{ Kind: yaml.ScalarNode, Value: id, }) } + slices.SortFunc(rawNeeds.Content, func(left, right *yaml.Node) int { return strings.Compare(left.Value, right.Value) }) workflow.Jobs[jobID].RawNeeds = rawNeeds diff --git a/internal/app/run/workflow_test.go b/internal/app/run/workflow_test.go index b0cd031d..8e91d187 100644 --- a/internal/app/run/workflow_test.go +++ b/internal/app/run/workflow_test.go @@ -13,21 +13,17 @@ import ( ) func Test_generateWorkflow(t *testing.T) { - type args struct { - task *runnerv1.Task - } tests := []struct { name string - args args + payload string + needs map[string]*runnerv1.TaskNeed assert func(t *testing.T, wf *model.Workflow) - want1 string + jobID string wantErr bool }{ { name: "has needs", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Build and deploy on: push @@ -39,34 +35,29 @@ jobs: - uses: actions/checkout@v3 - run: ./deploy --build ${{ needs.job1.outputs.output1 }} - run: ./deploy --build ${{ needs.job2.outputs.output2 }} -`), - Needs: map[string]*runnerv1.TaskNeed{ - "job1": { - Outputs: map[string]string{ - "output1": "output1 value", - }, - Result: runnerv1.Result_RESULT_SUCCESS, - }, - "job2": { - Outputs: map[string]string{ - "output2": "output2 value", - }, - Result: runnerv1.Result_RESULT_SUCCESS, - }, +`, + needs: map[string]*runnerv1.TaskNeed{ + "job1": { + Outputs: map[string]string{ + "output1": "output1 value", }, + Result: runnerv1.Result_RESULT_SUCCESS, + }, + "job2": { + Outputs: map[string]string{ + "output2": "output2 value", + }, + Result: runnerv1.Result_RESULT_SUCCESS, }, }, assert: func(t *testing.T, wf *model.Workflow) { require.Equal(t, []string{"job1", "job2"}, wf.GetJob("job9").Needs()) }, - want1: "job9", - wantErr: false, + jobID: "job9", }, { name: "no needs", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Simple workflow on: push @@ -76,23 +67,18 @@ jobs: steps: - uses: actions/checkout@v3 - run: echo "hello" -`), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, +`, + needs: map[string]*runnerv1.TaskNeed{}, assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("test") require.Equal(t, []string{}, job.Needs()) require.Len(t, job.Steps, 2) }, - want1: "test", - wantErr: false, + jobID: "test", }, { name: "needs list", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Workflow with list needs on: push @@ -102,40 +88,34 @@ jobs: runs-on: ubuntu-latest steps: - run: echo "deploying" -`), - Needs: map[string]*runnerv1.TaskNeed{ - "build": { - Outputs: map[string]string{}, - Result: runnerv1.Result_RESULT_SUCCESS, - }, - "test": { - Outputs: map[string]string{ - "coverage": "80%", - }, - Result: runnerv1.Result_RESULT_SUCCESS, - }, - "lint": { - Outputs: map[string]string{}, - Result: runnerv1.Result_RESULT_FAILURE, - }, +`, + needs: map[string]*runnerv1.TaskNeed{ + "build": { + Outputs: map[string]string{}, + Result: runnerv1.Result_RESULT_SUCCESS, + }, + "test": { + Outputs: map[string]string{ + "coverage": "80%", }, + Result: runnerv1.Result_RESULT_SUCCESS, + }, + "lint": { + Outputs: map[string]string{}, + Result: runnerv1.Result_RESULT_FAILURE, }, }, assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("deploy") - needs := job.Needs() - require.Equal(t, []string{"build", "lint", "test"}, needs) + require.Equal(t, []string{"build", "lint", "test"}, job.Needs()) require.Equal(t, "80%", wf.Jobs["test"].Outputs["coverage"]) require.Equal(t, "failure", wf.Jobs["lint"].Result) }, - want1: "deploy", - wantErr: false, + jobID: "deploy", }, { name: "workflow env and defaults", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Complex workflow on: push: @@ -159,25 +139,19 @@ jobs: node-version: "18" - run: npm ci - run: npm run build -`), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, +`, + needs: map[string]*runnerv1.TaskNeed{}, assert: func(t *testing.T, wf *model.Workflow) { require.Equal(t, "Complex workflow", wf.Name) require.Equal(t, "production", wf.Env["NODE_ENV"]) require.Equal(t, "true", wf.Env["CI"]) - job := wf.GetJob("build") - require.Len(t, job.Steps, 4) + require.Len(t, wf.GetJob("build").Steps, 4) }, - want1: "build", - wantErr: false, + jobID: "build", }, { name: "job with container and services", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Integration tests on: push @@ -192,24 +166,18 @@ jobs: steps: - uses: actions/checkout@v3 - run: npm test -`), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, +`, + needs: map[string]*runnerv1.TaskNeed{}, assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("integration") - container := job.Container() - require.Equal(t, "node:18", container.Image) + require.Equal(t, "node:18", job.Container().Image) require.Equal(t, "postgres:15", job.Services["postgres"].Image) }, - want1: "integration", - wantErr: false, + jobID: "integration", }, { name: "job with matrix strategy", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Matrix build on: push @@ -222,40 +190,28 @@ jobs: steps: - uses: actions/checkout@v3 - run: go test ./... -`), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, +`, + needs: map[string]*runnerv1.TaskNeed{}, assert: func(t *testing.T, wf *model.Workflow) { - job := wf.GetJob("test") - matrixes, err := job.GetMatrixes() + matrixes, err := wf.GetJob("test").GetMatrixes() require.NoError(t, err) require.Len(t, matrixes, 2) }, - want1: "test", - wantErr: false, + jobID: "test", }, { - name: "special yaml characters in values", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte("name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n"), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, + name: "special yaml characters in values", + payload: "name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n", + needs: map[string]*runnerv1.TaskNeed{}, assert: func(t *testing.T, wf *model.Workflow) { require.Equal(t, "Special: characters & test", wf.Name) - job := wf.GetJob("test") - require.Len(t, job.Steps, 3) + require.Len(t, wf.GetJob("test").Steps, 3) }, - want1: "test", - wantErr: false, + jobID: "test", }, { name: "invalid yaml", - args: args{ - task: &runnerv1.Task{ - WorkflowPayload: []byte(` + payload: ` name: Bad workflow on: push jobs: @@ -264,25 +220,21 @@ jobs: steps: - run: echo "ok" bad-indent: true -`), - Needs: map[string]*runnerv1.TaskNeed{}, - }, - }, - assert: nil, - want1: "", +`, + needs: map[string]*runnerv1.TaskNeed{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, got1, err := generateWorkflow(tt.args.task) + got, got1, err := generateWorkflow(&runnerv1.Task{WorkflowPayload: []byte(tt.payload), Needs: tt.needs}) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) tt.assert(t, got) - require.Equal(t, tt.want1, got1) + require.Equal(t, tt.jobID, got1) }) } } @@ -321,7 +273,6 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) { }) t.Run("node kind constants", func(t *testing.T) { - // Verify yaml/v4 node kind constants are usable (same API as v3) require.NotEqual(t, yaml.ScalarNode, yaml.SequenceNode) require.NotEqual(t, yaml.SequenceNode, yaml.MappingNode) }) diff --git a/internal/pkg/action/checkout/checkout.go b/internal/pkg/action/checkout/checkout.go index 7008266f..dd609d18 100644 --- a/internal/pkg/action/checkout/checkout.go +++ b/internal/pkg/action/checkout/checkout.go @@ -56,7 +56,7 @@ func Main(ctx context.Context, c *action.Context) (err error) { ref := c.Inputs["ref"] sshKey := strings.TrimSpace(c.Inputs["ssh-key"]) isWorkflowRepo := strings.EqualFold(repository, ghc.Repository) - local := isWorkflowRepo && slices.Contains([]string{"", shortBranch(ghc.Ref), ghc.Sha}, shortBranch(ref)) && !c.NoSkipCheckout && sshKey == "" + local := isWorkflowRepo && slices.Contains([]string{"", strings.TrimPrefix(ghc.Ref, "refs/heads/"), ghc.Sha}, strings.TrimPrefix(ref, "refs/heads/")) && !c.NoSkipCheckout && sshKey == "" if local && (!c.BindWorkdir || dst == c.Workspace) { if !c.BindWorkdir { if err := c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx); err != nil { @@ -470,7 +470,3 @@ func fetchDepth(input string) (int, error) { } return depth, nil } - -func shortBranch(ref string) string { // full tag refs stay exact - return strings.TrimPrefix(ref, "refs/heads/") -} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c8063bb2..ace85d59 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -246,7 +246,7 @@ type Config struct { func LoadDefault(file string) (*Config, error) { // Seeded before the file is read, so a written 0 can mean off. cfg := &Config{Cache: DefaultCache(), Log: Log{Job: LogJob{Retention: 7 * 24 * time.Hour, MaxSize: 1024 * 1024 * 1024}}} - definedRunnerKeys := map[string]bool{} + var definedRunnerKeys map[string]bool if file != "" { content, err := os.ReadFile(file) if err != nil { @@ -307,16 +307,13 @@ func LoadDefault(file string) (*Config, error) { cfg.Runner.Timeout = 3 * time.Hour } if cfg.Runner.ActionShallowClone == nil { - b := true - cfg.Runner.ActionShallowClone = &b + cfg.Runner.ActionShallowClone = new(true) } if cfg.Runner.SetActEnv == nil { - b := true - cfg.Runner.SetActEnv = &b + cfg.Runner.SetActEnv = new(true) } if cfg.Cache.Enabled == nil { - b := true - cfg.Cache.Enabled = &b + cfg.Cache.Enabled = new(true) } // Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled. if err := resolveSecretFile("cache.external_secret", &cfg.Cache.ExternalSecret, cfg.Cache.ExternalSecretFile); err != nil { @@ -435,12 +432,7 @@ func LoadDefault(file string) (*Config, error) { // although `container.network_mode` will be deprecated, but we have to be compatible with it for now. if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" { logging.Warn("You are trying to use deprecated configuration item of `container.network_mode`, please use `container.network` instead.") - if cfg.Container.NetworkMode == "bridge" { - // Previously, if the value of `container.network_mode` is `bridge`, we will create a new network for job. - // But “bridge” is easily confused with the bridge network created by Docker by default. - // So we set the value of `container.network` to empty string to make `runner` automatically create a new network for job. - cfg.Container.Network = "" - } else { + if cfg.Container.NetworkMode != "bridge" { // "bridge" meant a per-job network, which an empty container.network creates cfg.Container.Network = cfg.Container.NetworkMode } } diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index d1de61c0..845c7f99 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -13,6 +13,7 @@ import ( "math/big" "os" "path/filepath" + "strings" "testing" "time" @@ -22,34 +23,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadDefault_RejectsExternalServerWithoutSecret(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" -`), 0o600)) - - _, err := LoadDefault(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "external_secret") -} - -func TestLoadDefault_AcceptsExternalServerWithSecret(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" - external_secret: "shh" -`), 0o600)) - - _, err := LoadDefault(path) - require.NoError(t, err) -} - func TestClientCertIsValidatedAndAbsoluteOnLoadReadOnUseAndOfferedOnlyWhenAccepted(t *testing.T) { require.True(t, (&Runner{Insecure: true}).TLSConfig().InsecureSkipVerify) @@ -156,13 +129,8 @@ cache: } func TestLoadDefault_ToolCacheMode(t *testing.T) { - cfg, err := LoadDefault("") - require.NoError(t, err) - assert.Equal(t, ToolCacheModeNone, cfg.Runner.ToolCacheMode) - - path := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(path, []byte("runner:\n tool_cache_mode: shared\n"), 0o600)) - cfg, err = LoadDefault(path) + path := write(t, "runner:\n tool_cache_mode: shared\n") + cfg, err := LoadDefault(path) require.NoError(t, err) assert.Equal(t, ToolCacheModeShared, cfg.Runner.ToolCacheMode) @@ -178,9 +146,7 @@ func TestLoadDefault_RejectsInvalidLogReportLevel(t *testing.T) { } func TestLoadDefault_LoadsExtraHeaders(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(path, []byte("runner:\n extra_headers:\n X-Proxy-Token: proxy-token\n"), 0o600)) - + path := write(t, "runner:\n extra_headers:\n X-Proxy-Token: proxy-token\n") cfg, err := LoadDefault(path) require.NoError(t, err) assert.Equal(t, map[string]string{"X-Proxy-Token": "proxy-token"}, cfg.Runner.ExtraHeaders) @@ -196,17 +162,13 @@ func TestLoadDefault_LoadsExtraHeaders(t *testing.T) { } } -func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) { +func TestLoadDefault_Defaults(t *testing.T) { cfg, err := LoadDefault("") require.NoError(t, err) assert.Equal(t, 24*time.Hour, cfg.Runner.WorkdirCleanupAge) assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval) assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage) -} - -func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) { - cfg, err := LoadDefault("") - require.NoError(t, err) + assert.Equal(t, ToolCacheModeNone, cfg.Runner.ToolCacheMode) assert.False(t, cfg.HealthCheck.Enabled) assert.Equal(t, int64(1024), cfg.HealthCheck.MinFreeDiskSpaceMB) assert.Empty(t, cfg.HealthCheck.Script) @@ -216,9 +178,7 @@ func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) { } func TestLoadDefault_DiskAndReadinessSettings(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` + cfg, err := LoadDefault(write(t, ` health_check: enabled: true min_free_disk_space_mb: 4096 @@ -227,9 +187,7 @@ health_check: timeout: 3s metrics: readiness_grace: 45s -`), 0o600)) - - cfg, err := LoadDefault(path) +`)) require.NoError(t, err) assert.True(t, cfg.HealthCheck.Enabled) assert.Equal(t, int64(4096), cfg.HealthCheck.MinFreeDiskSpaceMB) @@ -239,88 +197,40 @@ metrics: assert.Equal(t, 45*time.Second, cfg.Metrics.ReadinessGrace) } -func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - workdir_cleanup_age: 2h30m -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, 150*time.Minute, cfg.Runner.WorkdirCleanupAge) -} - -func TestLoadDefault_UsesConfiguredIdleCleanupInterval(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - idle_cleanup_interval: 45m -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, 45*time.Minute, cfg.Runner.IdleCleanupInterval) -} - -func TestLoadDefault_AllowsDisablingWorkdirCleanup(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - workdir_cleanup_age: 0s - idle_cleanup_interval: 0s -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, time.Duration(0), cfg.Runner.WorkdirCleanupAge) - assert.Equal(t, time.Duration(0), cfg.Runner.IdleCleanupInterval) -} - -func TestLoadDefault_AllowsNegativeWorkdirCleanupValues(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - workdir_cleanup_age: -1s - idle_cleanup_interval: -1s -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, -1*time.Second, cfg.Runner.WorkdirCleanupAge) - assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval) +func TestLoadDefault_WorkdirCleanup(t *testing.T) { + for _, tc := range []struct { + name, yaml string + age, interval time.Duration + }{ + {"configured age", "\nrunner:\n workdir_cleanup_age: 2h30m\n", 150 * time.Minute, 10 * time.Minute}, + {"configured interval", "\nrunner:\n idle_cleanup_interval: 45m\n", 24 * time.Hour, 45 * time.Minute}, + {"disabled", "\nrunner:\n workdir_cleanup_age: 0s\n idle_cleanup_interval: 0s\n", 0, 0}, + {"negative", "\nrunner:\n workdir_cleanup_age: -1s\n idle_cleanup_interval: -1s\n", -time.Second, -time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := LoadDefault(write(t, tc.yaml)) + require.NoError(t, err) + assert.Equal(t, tc.age, cfg.Runner.WorkdirCleanupAge) + assert.Equal(t, tc.interval, cfg.Runner.IdleCleanupInterval) + }) + } } func TestLoadDefault_LoadsPostTaskScript(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - post_task_script: /usr/local/bin/post-task.sh - post_task_script_timeout: 2m -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript) - assert.Equal(t, 2*time.Minute, cfg.Runner.PostTaskScriptTimeout) -} - -func TestLoadDefault_DefaultsPostTaskScriptTimeout(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -runner: - post_task_script: /usr/local/bin/post-task.sh -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout) + for _, tc := range []struct { + name, timeout string + want time.Duration + }{ + {"configured timeout", " post_task_script_timeout: 2m\n", 2 * time.Minute}, + {"default timeout", "", 5 * time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := LoadDefault(write(t, "\nrunner:\n post_task_script: /usr/local/bin/post-task.sh\n"+tc.timeout)) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript) + assert.Equal(t, tc.want, cfg.Runner.PostTaskScriptTimeout) + }) + } } func write(t *testing.T, body string) string { @@ -363,43 +273,28 @@ func TestLoadDefault_LoadsJobLogs(t *testing.T) { } func TestLoadDefault_LoadsJobHooks(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` + cfg, err := LoadDefault(write(t, ` runner: hooks: job_started: /hooks/started.sh job_completed: /hooks/completed.sh -`), 0o600)) - - cfg, err := LoadDefault(path) +`)) require.NoError(t, err) assert.Equal(t, "/hooks/started.sh", cfg.Runner.Hooks.JobStarted) assert.Equal(t, "/hooks/completed.sh", cfg.Runner.Hooks.JobCompleted) } -// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for -// invalid YAML to the canonical "parse config file" message rather than the -// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first. func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte("runner:\n capacity: [unterminated\n"), 0o600)) - - _, err := LoadDefault(path) + _, err := LoadDefault(write(t, "runner:\n capacity: [unterminated\n")) require.Error(t, err) assert.Contains(t, err.Error(), "parse config file") assert.NotContains(t, err.Error(), "defaults metadata") } func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte("container:\n volumes:\n - /host:/ctr\n privileged: true\n"), 0o600)) - hook := logtest.SetDefault(t) - cfg, err := LoadDefault(path) + cfg, err := LoadDefault(write(t, "container:\n volumes:\n - /host:/ctr\n privileged: true\n")) require.NoError(t, err) assert.True(t, cfg.Container.Privileged) require.Len(t, hook.AllRecords(), 1) @@ -407,142 +302,70 @@ func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) { } func TestContainerNetworkCreateOptions(t *testing.T) { - // Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields, - // distinguishing an explicit true/false from an omitted key (nil). A nil here is - // forwarded as-is to Docker, which applies its own default. - loadOptions := func(t *testing.T, yaml string) ContainerNetworkCreateOptions { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - return cfg.Container.NetworkCreateOptions + for _, tc := range []struct { + name, yaml string + want ContainerNetworkCreateOptions + }{ + {"enable_ipv6 true unmarshals to non-nil true", "container:\n network_create_options:\n enable_ipv6: true\n", ContainerNetworkCreateOptions{EnableIPv6: new(true)}}, + {"enable_ipv6 false unmarshals to non-nil false", "container:\n network_create_options:\n enable_ipv6: false\n", ContainerNetworkCreateOptions{EnableIPv6: new(false)}}, + {"enable_ipv4 false unmarshals to non-nil false", "container:\n network_create_options:\n enable_ipv4: false\n", ContainerNetworkCreateOptions{EnableIPv4: new(false)}}, + {"omitted keys stay nil", "container:\n network_create_options:\n enable_ipv4: true\n", ContainerNetworkCreateOptions{EnableIPv4: new(true)}}, + {"omitted block leaves both nil", "container:\n network: \"\"\n", ContainerNetworkCreateOptions{}}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := LoadDefault(write(t, tc.yaml)) + require.NoError(t, err) + require.Equal(t, tc.want, cfg.Container.NetworkCreateOptions) + }) } - - t.Run("enable_ipv6 true unmarshals to non-nil true", func(t *testing.T) { - opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: true\n") - require.NotNil(t, opts.EnableIPv6) - assert.True(t, *opts.EnableIPv6) - }) - - t.Run("enable_ipv6 false unmarshals to non-nil false", func(t *testing.T) { - opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: false\n") - require.NotNil(t, opts.EnableIPv6) - assert.False(t, *opts.EnableIPv6) - }) - - t.Run("enable_ipv4 false unmarshals to non-nil false", func(t *testing.T) { - opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: false\n") - require.NotNil(t, opts.EnableIPv4) - assert.False(t, *opts.EnableIPv4) - }) - - t.Run("omitted keys stay nil", func(t *testing.T) { - opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: true\n") - require.NotNil(t, opts.EnableIPv4) - assert.True(t, *opts.EnableIPv4) - assert.Nil(t, opts.EnableIPv6, "an omitted enable_ipv6 must remain nil so Docker's default applies") - }) - - t.Run("omitted block leaves both nil", func(t *testing.T) { - opts := loadOptions(t, "container:\n network: \"\"\n") - assert.Nil(t, opts.EnableIPv4) - assert.Nil(t, opts.EnableIPv6) - }) } -func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) { - dir := t.TempDir() - secretPath := filepath.Join(dir, "cache.secret") - require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600)) - - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" - external_secret_file: "`+secretPath+`" -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) +func TestLoadDefault_ExternalCacheSecret(t *testing.T) { + const externalCache = "\ncache:\n enabled: true\n external_server: \"http://cache.invalid/\"\n" + for _, tc := range []struct { + name, yaml, fileContent, wantSecret, wantErr string + }{ + {name: "without secret", yaml: externalCache, wantErr: "external_secret"}, + {name: "inline secret", yaml: externalCache + " external_secret: \"shh\"\n"}, + { + name: "from file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n", + fileContent: " s3cr3t\n", wantSecret: "s3cr3t", + }, + { + name: "from file when disabled", yaml: "\ncache:\n enabled: false\n external_secret_file: \"$DIR/cache.secret\"\n", + fileContent: "s3cr3t", wantSecret: "s3cr3t", + }, + { + name: "inline and file conflict", yaml: externalCache + " external_secret: \"inline\"\n external_secret_file: \"$DIR/cache.secret\"\n", + fileContent: "s3cr3t", wantErr: "both set", + }, + {name: "missing file", yaml: externalCache + " external_secret_file: \"$DIR/absent.secret\"\n", wantErr: "read cache.external_secret_file"}, + { + name: "empty file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n", + fileContent: "\n \n", wantErr: "contains no secret", + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if tc.fileContent != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "cache.secret"), []byte(tc.fileContent), 0o600)) + } + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(tc.yaml, "$DIR", dir)), 0o600)) + cfg, err := LoadDefault(path) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + require.NoError(t, err) + if tc.wantSecret != "" { + assert.Equal(t, tc.wantSecret, cfg.Cache.ExternalSecret) + } + } + }) + } } -func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) { - dir := t.TempDir() - secretPath := filepath.Join(dir, "cache.secret") - require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) - - // the file has to be resolved even when cache is disabled - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: false - external_secret_file: "`+secretPath+`" -`), 0o600)) - - cfg, err := LoadDefault(path) - require.NoError(t, err) - assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) -} - -func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) { - dir := t.TempDir() - secretPath := filepath.Join(dir, "cache.secret") - require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) - - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" - external_secret: "inline" - external_secret_file: "`+secretPath+`" -`), 0o600)) - - _, err := LoadDefault(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "both set") -} - -func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" - external_secret_file: "`+filepath.Join(dir, "absent.secret")+`" -`), 0o600)) - - _, err := LoadDefault(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "read cache.external_secret_file") -} - -func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) { - dir := t.TempDir() - secretPath := filepath.Join(dir, "cache.secret") - require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600)) - - path := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -cache: - enabled: true - external_server: "http://cache.invalid/" - external_secret_file: "`+secretPath+`" -`), 0o600)) - - _, err := LoadDefault(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "contains no secret") -} - -// The shipped configs must parse, hold no key the config does not know, and leave -// every option at its default, as all of their values are commented out. func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) { hook := logtest.SetDefault(t) @@ -562,13 +385,10 @@ func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) { } func TestLoadDefault_ClampsFetchTimeout(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(` + cfg, err := LoadDefault(write(t, ` runner: fetch_timeout: 120s -`), 0o600)) - - cfg, err := LoadDefault(path) +`)) require.NoError(t, err) assert.Equal(t, RequestTimeout, cfg.Runner.FetchTimeout) } diff --git a/internal/pkg/config/edit.go b/internal/pkg/config/edit.go index 9d2678d4..ed91e3c5 100644 --- a/internal/pkg/config/edit.go +++ b/internal/pkg/config/edit.go @@ -277,7 +277,7 @@ func mappingValue(mapping *yaml.Node, key string) *yaml.Node { } func renderNode(node *yaml.Node) (string, error) { - if !allScalars(node.Content) { + if slices.ContainsFunc(node.Content, func(child *yaml.Node) bool { return child.Kind != yaml.ScalarNode }) { encoded, err := encodeYAML(node) if err != nil { return "", err @@ -303,15 +303,6 @@ func renderNode(node *yaml.Node) (string, error) { } } -func allScalars(nodes []*yaml.Node) bool { - for _, node := range nodes { - if node.Kind != yaml.ScalarNode { - return false - } - } - return true -} - // encodeYAML never wraps lines, re-encoding must not reflow long values. func encodeYAML(node *yaml.Node) ([]byte, error) { return yaml.Dump(node, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1)) diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go index a4910d9e..bf5317f9 100644 --- a/internal/pkg/labels/labels.go +++ b/internal/pkg/labels/labels.go @@ -5,6 +5,7 @@ package labels import ( "errors" + "slices" "strings" ) @@ -31,17 +32,10 @@ func Parse(str string) (*Label, error) { return nil, errors.New("empty label") } - splits := strings.SplitN(str, ":", 3) - label := &Label{ - Name: splits[0], - Schema: SchemeHost, - Arg: "", - } - if len(splits) >= 2 { - label.Schema = splits[1] - } - if len(splits) >= 3 { - label.Arg = splits[2] + name, remainder, hasSchema := strings.Cut(str, ":") + label := &Label{Name: name, Schema: SchemeHost} + if hasSchema { + label.Schema, label.Arg, _ = strings.Cut(remainder, ":") } kubernetes := label.Schema == SchemeKubernetes && strings.HasPrefix(label.Arg, "//") && len(label.Arg) > 2 if label.Schema != SchemeHost && label.Schema != SchemeDocker && !kubernetes { @@ -58,12 +52,7 @@ func Parse(str string) (*Label, error) { type Labels []*Label func (l Labels) RequireDocker() bool { - for _, label := range l { - if label.Schema == SchemeDocker { - return true - } - } - return false + return slices.ContainsFunc(l, func(label *Label) bool { return label.Schema == SchemeDocker }) } // PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "". diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index 6ba226b3..befc2ddb 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -11,67 +11,20 @@ import ( func TestParse(t *testing.T) { tests := []struct { - args string - want *Label - wantErr bool + args string + want *Label }{ - { - args: "ubuntu:docker://node:18", - want: &Label{ - Name: "ubuntu", - Schema: "docker", - Arg: "//node:18", - }, - wantErr: false, - }, - { - args: "ubuntu:host", - want: &Label{ - Name: "ubuntu", - Schema: "host", - Arg: "", - }, - wantErr: false, - }, - { - args: "ubuntu", - want: &Label{ - Name: "ubuntu", - Schema: "host", - Arg: "", - }, - wantErr: false, - }, - { - args: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", - want: &Label{ - Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", - Schema: "host", - Arg: "", - Opaque: true, - }, - wantErr: false, - }, - { - args: "ubuntu:vm:ubuntu-18.04", - want: &Label{ - Name: "ubuntu:vm:ubuntu-18.04", - Schema: "host", - Arg: "", - Opaque: true, - }, - wantErr: false, - }, - { - args: "", - want: nil, - wantErr: true, - }, + {"ubuntu:docker://node:18", &Label{Name: "ubuntu", Schema: "docker", Arg: "//node:18"}}, + {"ubuntu:host", &Label{Name: "ubuntu", Schema: "host"}}, + {"ubuntu", &Label{Name: "ubuntu", Schema: "host"}}, + {"pool:e57e18d4-10d4-406f-93bf-60f127221bdd", &Label{Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", Schema: "host", Opaque: true}}, + {"ubuntu:vm:ubuntu-18.04", &Label{Name: "ubuntu:vm:ubuntu-18.04", Schema: "host", Opaque: true}}, + {"", nil}, } for _, tt := range tests { t.Run(tt.args, func(t *testing.T) { got, err := Parse(tt.args) - if tt.wantErr { + if tt.want == nil { require.Error(t, err) return } @@ -81,7 +34,6 @@ func TestParse(t *testing.T) { } } -// mustParse parses the given label strings, failing the test on any error. func mustParse(t *testing.T, strs ...string) Labels { t.Helper() ls := make(Labels, 0, len(strs)) @@ -157,7 +109,6 @@ func TestToStrings(t *testing.T) { }, ls.ToStrings()) } -// a colon-containing name must survive a write to and read back from the .runner file func TestOpaqueLabelRoundTrip(t *testing.T) { const raw = "pool:e57e18d4-10d4-406f-93bf-60f127221bdd" diff --git a/internal/pkg/lock/lock_test.go b/internal/pkg/lock/lock_test.go index b7513611..fcd23acc 100644 --- a/internal/pkg/lock/lock_test.go +++ b/internal/pkg/lock/lock_test.go @@ -6,49 +6,26 @@ package lock import ( - "errors" "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) func TestTryLock(t *testing.T) { runnerFile := filepath.Join(t.TempDir(), ".runner") - release, err := TryLock(runnerFile) - if err != nil { - t.Fatalf("first TryLock failed: %v", err) - } - - // A second lock on the same file must be refused while the first is held. - if _, err := TryLock(runnerFile); !errors.Is(err, ErrLocked) { - t.Fatalf("second TryLock: want ErrLocked, got %v", err) - } - - if err := release(); err != nil { - t.Fatalf("release failed: %v", err) - } - - // After release the lock is available again. - release2, err := TryLock(runnerFile) - if err != nil { - t.Fatalf("TryLock after release failed: %v", err) - } - if err := release2(); err != nil { - t.Fatalf("second release failed: %v", err) + for range 2 { + release, err := TryLock(runnerFile) + require.NoError(t, err) + _, err = TryLock(runnerFile) + require.ErrorIs(t, err, ErrLocked) + require.NoError(t, release()) } } -// TestTryLockUncreatable ensures a lock file that cannot be created reports a -// non-ErrLocked error, so callers can tell "already locked by another process" -// apart from "couldn't lock" and degrade gracefully (e.g. a read-only mount). func TestTryLockUncreatable(t *testing.T) { - runnerFile := filepath.Join(t.TempDir(), "missing-dir", ".runner") - - _, err := TryLock(runnerFile) - if err == nil { - t.Fatal("TryLock on an uncreatable lock file: want error, got nil") - } - if errors.Is(err, ErrLocked) { - t.Fatal("TryLock on an uncreatable lock file: want non-ErrLocked error, got ErrLocked") - } + _, err := TryLock(filepath.Join(t.TempDir(), "missing-dir", ".runner")) + require.Error(t, err) + require.NotErrorIs(t, err, ErrLocked) } diff --git a/internal/pkg/metrics/server_test.go b/internal/pkg/metrics/server_test.go index d3fa8a66..b2a667ae 100644 --- a/internal/pkg/metrics/server_test.go +++ b/internal/pkg/metrics/server_test.go @@ -11,33 +11,24 @@ import ( "github.com/stretchr/testify/assert" ) -func TestReadyEndpoint(t *testing.T) { +func TestHealthAndReadyEndpoints(t *testing.T) { tests := []struct { - name string - ready bool - reason string - status int + name, path string + ready bool + reason, body string + status int }{ - {"ready", true, "ok", http.StatusOK}, - {"not ready", false, "low disk space", http.StatusServiceUnavailable}, + {"ready", "/readyz", true, "ok", "ok", http.StatusOK}, + {"not ready", "/readyz", false, "low disk space", "low disk space", http.StatusServiceUnavailable}, + {"live when not ready", "/healthz", false, "low disk space", "ok", http.StatusOK}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { handler := NewHTTPHandler(func() (bool, string) { return test.ready, test.reason }) - request := httptest.NewRequest(http.MethodGet, "/readyz", nil) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil)) assert.Equal(t, test.status, response.Code) - assert.Equal(t, test.reason, response.Body.String()) + assert.Equal(t, test.body, response.Body.String()) }) } } - -func TestHealthEndpointStaysLiveWhenNotReady(t *testing.T) { - handler := NewHTTPHandler(func() (bool, string) { return false, "low disk space" }) - request := httptest.NewRequest(http.MethodGet, "/healthz", nil) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - assert.Equal(t, http.StatusOK, response.Code) - assert.Equal(t, "ok", response.Body.String()) -} diff --git a/internal/pkg/process/killer_windows_test.go b/internal/pkg/process/killer_windows_test.go index ad2d8461..04f315a9 100644 --- a/internal/pkg/process/killer_windows_test.go +++ b/internal/pkg/process/killer_windows_test.go @@ -5,11 +5,8 @@ package process import ( "fmt" - "os" "os/exec" "path/filepath" - "strconv" - "strings" "testing" "time" @@ -17,7 +14,6 @@ import ( "golang.org/x/sys/windows" ) -// processAlive reports whether pid refers to a still-running process. func processAlive(pid int) bool { h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { @@ -28,21 +24,13 @@ func processAlive(pid int) bool { if err := windows.GetExitCodeProcess(h, &code); err != nil { return false } - const stillActive = 259 // STILL_ACTIVE + const stillActive = 259 return code == stillActive } -// TestKillerKillsTree verifies that a process assigned to the Job Object is -// terminated together with a child it spawns afterwards. This mirrors a step or -// post-task script that launches a child which spawns further processes, where -// cancelling must take down the whole tree, not just the direct child. func TestKillerKillsTree(t *testing.T) { - dir := t.TempDir() - pidFile := filepath.Join(dir, "child.pid") + pidFile := filepath.Join(t.TempDir(), "child.pid") - // Parent powershell spawns a detached, long-lived child powershell (writing - // its PID to a file) and then sleeps. The child is launched AFTER the parent - // has been assigned to the job, so it must be captured by the job too. script := fmt.Sprintf( `$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+ `Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile) @@ -54,22 +42,8 @@ func TestKillerKillsTree(t *testing.T) { require.NoError(t, err) defer killer.Close() - // Wait for the child PID to be reported. - var childPID int - require.Eventually(t, func() bool { - b, e := os.ReadFile(pidFile) - if e != nil { - return false - } - s := strings.TrimSpace(string(b)) - if s == "" { - return false - } - childPID, _ = strconv.Atoi(s) - return childPID > 0 && processAlive(childPID) - }, 20*time.Second, 200*time.Millisecond, "child process should start") + childPID := awaitChildPID(t, pidFile) - // Killing the job must terminate both the parent and the detached child. require.NoError(t, killer.Kill()) require.Eventually(t, func() bool { diff --git a/internal/pkg/process/leftover.go b/internal/pkg/process/leftover.go index 396ab10b..ea4f9f58 100644 --- a/internal/pkg/process/leftover.go +++ b/internal/pkg/process/leftover.go @@ -8,22 +8,14 @@ import ( "strings" ) -// pathWithin is the OS-agnostic core of dirContainsPath: dir and target must -// already be cleaned, and fold folds case. -func pathWithin(dir, target string, fold bool) bool { - if dir == "" || target == "" || dir == "." || target == "." { +func dirContainsPath(dir, target string) bool { + dir = strings.ToLower(filepath.Clean(dir)) + target = strings.ToLower(filepath.Clean(target)) + if dir == "." || target == "." { return false } - if fold { - dir = strings.ToLower(dir) - target = strings.ToLower(target) - } if dir == target { return true } - sep := string(filepath.Separator) - if !strings.HasSuffix(dir, sep) { - dir += sep - } - return strings.HasPrefix(target, dir) + return strings.HasPrefix(target, strings.TrimSuffix(dir, string(filepath.Separator))+string(filepath.Separator)) } diff --git a/internal/pkg/process/leftover_test.go b/internal/pkg/process/leftover_test.go index b547a4ad..c480a0bc 100644 --- a/internal/pkg/process/leftover_test.go +++ b/internal/pkg/process/leftover_test.go @@ -8,33 +8,31 @@ import ( "testing" ) -func TestPathWithin(t *testing.T) { +func TestDirContainsPath(t *testing.T) { base := filepath.Join("base", "job1") cases := []struct { name string dir string target string - fold bool want bool }{ - {"same dir", base, base, false, true}, - {"direct child", base, filepath.Join(base, "app.exe"), false, true}, - {"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), false, true}, - {"parent is not within child", filepath.Join(base, "sub"), base, false, false}, - {"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false, false}, - {"unrelated", base, filepath.Join("other", "app.exe"), false, false}, - {"empty dir", "", base, false, false}, - {"empty target", base, "", false, false}, - {"dot dir", ".", base, false, false}, - {"case-sensitive miss", base, filepath.Join("base", "JOB1", "app.exe"), false, false}, - {"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true, true}, + {"same dir", base, base, true}, + {"direct child", base, filepath.Join(base, "app.exe"), true}, + {"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), true}, + {"parent is not within child", filepath.Join(base, "sub"), base, false}, + {"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false}, + {"unrelated", base, filepath.Join("other", "app.exe"), false}, + {"empty dir", "", base, false}, + {"empty target", base, "", false}, + {"dot dir", ".", base, false}, + {"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := pathWithin(filepath.Clean(tc.dir), filepath.Clean(tc.target), tc.fold) + got := dirContainsPath(tc.dir, tc.target) if got != tc.want { - t.Fatalf("pathWithin(%q, %q, fold=%v) = %v, want %v", tc.dir, tc.target, tc.fold, got, tc.want) + t.Fatalf("dirContainsPath(%q, %q) = %v, want %v", tc.dir, tc.target, got, tc.want) } }) } diff --git a/internal/pkg/process/leftover_windows.go b/internal/pkg/process/leftover_windows.go index ae1b5f17..46c1d450 100644 --- a/internal/pkg/process/leftover_windows.go +++ b/internal/pkg/process/leftover_windows.go @@ -8,18 +8,11 @@ import ( "errors" "fmt" "os" - "path/filepath" "unsafe" "golang.org/x/sys/windows" ) -// dirContainsPath reports whether target is dir or lives beneath it, -// case-insensitively, so a name-prefix sibling (job1 vs job10) is not a match. -func dirContainsPath(dir, target string) bool { - return pathWithin(filepath.Clean(dir), filepath.Clean(target), true) -} - // KillProcessesWithCWDUnder terminates every process in the runner's session // whose cwd is one of dirs or below, catching leftovers the path scan misses // (Win32_Process exposes no cwd) that still pin a handle. Best-effort. diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index c8e742ff..45bbb47e 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -12,7 +12,6 @@ import ( "slices" "strings" "sync" - "sync/atomic" "time" "gitea.com/gitea/runner/act/common/logging" @@ -80,9 +79,7 @@ type Reporter struct { heartbeatStop chan struct{} heartbeatStopOnce sync.Once - // Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat - // guard in ReportState reads it without contending stateMu. - lastReportedAtNanos atomic.Int64 + lastReportedAtNanos int64 // guarded by clientM // Adaptive batching control logReportInterval time.Duration @@ -110,14 +107,8 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C for _, v := range extraMasks { oldnew = runner.AppendSecretMasker(oldnew, v) } - if v := task.Context.Fields["token"].GetStringValue(); v != "" { - oldnew = runner.AppendSecretMasker(oldnew, v) - } - if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" { - oldnew = runner.AppendSecretMasker(oldnew, v) - } - if v := task.Context.Fields["actions_id_token_request_token"].GetStringValue(); v != "" { - oldnew = runner.AppendSecretMasker(oldnew, v) + for _, key := range []string{"token", "gitea_runtime_token", "actions_id_token_request_token"} { + oldnew = runner.AppendSecretMasker(oldnew, task.Context.Fields[key].GetStringValue()) } oldnew = runner.AppendSecretMaskers(oldnew, task.Secrets) @@ -239,10 +230,7 @@ func (r *Reporter) reportedStepOf(entry logging.Record) (*runnerv1.StepState, bo default: return r.currentReportedStep, false } - main, ok := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}] - if !ok { - return r.currentReportedStep, false - } + main := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}] if evaluated, _ := recordAttr[string](entry, "step"); evaluated != "" { // evaluated as GitHub shows it, and masked as an expression can name a secret main.Name = r.mask(stepDisplayName(r.stepModels[number], evaluated)) @@ -358,16 +346,10 @@ func attrArgs(attrs []logging.Attr) []any { return args } -// isJobStepEntry is used to not report composite step results incorrectly as step result -// returns true if the logentry is on job level -// returns false for composite action step messages +// isJobStepEntry is false for composite action steps, whose results are not job step results. func isJobStepEntry(rec logging.Record) bool { - if v, ok := rec.Attr("stepID"); ok { - if v, ok := v.([]string); ok && len(v) > 1 { - return false - } - } - return true + stepID, _ := recordAttr[[]string](rec, "stepID") + return len(stepID) <= 1 } // notifyLog sends a non-blocking signal that new log rows are available. @@ -421,7 +403,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error { urgentState = true } if v, ok := entry.Attr("stepResult"); ok && own && isJobStepEntry(entry) { - if stepResult, ok := r.parseResult(v); ok { + if stepResult, ok := parseResult(v); ok { reported.Result = stepResult reported.StoppedAt = timestamppb.New(timestamp) urgentState = true @@ -433,7 +415,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error { if stage != runner.StageMain { if v, ok := entry.Attr("jobResult"); ok { - if jobResult, ok := r.parseResult(v); ok { + if jobResult, ok := parseResult(v); ok { // We need to ensure log upload before this upload r.state.Result = jobResult r.state.StoppedAt = timestamppb.New(timestamp) @@ -476,24 +458,19 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error { } // Force reporting log errors as raw output to prevent silent failures - v, ok := entry.Attr("raw_output") - if entry.Level >= logging.LevelError { - v, ok = true, true - } - - if ok { - if rawOutput, ok := v.(bool); ok && rawOutput { - if step.LogLength == 0 { - step.LogIndex = int64(r.logOffset + len(r.logRows)) - } - step.LogLength++ - r.appendLogRow(logRow(entry)) + v, hasRawOutput := entry.Attr("raw_output") + rawOutput, _ := v.(bool) + if entry.Level >= logging.LevelError || rawOutput { + if step.LogLength == 0 { + step.LogIndex = int64(r.logOffset + len(r.logRows)) } - } else if r.shouldAppendLogRow(entry) { + step.LogLength++ + r.appendLogRow(logRow(entry)) + } else if !hasRawOutput && r.shouldAppendLogRow(entry) { r.appendLogRow(logRow(entry)) } if v, ok := entry.Attr("stepResult"); ok && isJobStepEntry(entry) { - if stepResult, ok := r.parseResult(v); ok { + if stepResult, ok := parseResult(v); ok { if step.LogLength == 0 { step.LogIndex = int64(r.logOffset + len(r.logRows)) } @@ -535,18 +512,6 @@ func (r *Reporter) StopHeartbeats() { }) } -func (r *Reporter) stopLatencyTimer(active *bool, timer *time.Timer) { - if *active { - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - *active = false - } -} - func (r *Reporter) runDaemonLoop() { defer close(r.daemon) @@ -556,10 +521,12 @@ func (r *Reporter) runDaemonLoop() { // maxLatencyTimer ensures the first buffered log row is sent within logReportMaxLatency. // Start inactive — it is armed when the first log row arrives in an empty buffer. maxLatencyTimer := time.NewTimer(0) - if !maxLatencyTimer.Stop() { - <-maxLatencyTimer.C - } + maxLatencyTimer.Stop() maxLatencyActive := false + stopLatencyTimer := func() { + maxLatencyTimer.Stop() + maxLatencyActive = false + } defer logTicker.Stop() defer stateTicker.Stop() @@ -569,7 +536,7 @@ func (r *Reporter) runDaemonLoop() { select { case <-logTicker.C: r.reportLogWithState() - r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer) + stopLatencyTimer() case <-stateTicker.C: r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false)) @@ -581,7 +548,7 @@ func (r *Reporter) runDaemonLoop() { if n >= r.logBatchSize { r.reportLogWithState() - r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer) + stopLatencyTimer() } else if !maxLatencyActive && n > 0 { maxLatencyTimer.Reset(r.logReportMaxLatency) maxLatencyActive = true @@ -591,7 +558,7 @@ func (r *Reporter) runDaemonLoop() { // Step transition or job result — flush both immediately for frontend UX. r.noteReport(metrics.LabelMethodUpdateLog, r.ReportLog(false)) r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false)) - r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer) + stopLatencyTimer() case <-maxLatencyTimer.C: maxLatencyActive = false @@ -882,15 +849,14 @@ func (r *Reporter) ReportState(reportResult bool) error { // below so a log record handled during UpdateTask isn't silently lost. // Heartbeat at stateReportInterval even when nothing changed, so the server // doesn't time out long-running silent jobs as orphaned (#826). - last := r.lastReportedAtNanos.Load() + last := r.lastReportedAtNanos withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval r.stateMu.Lock() if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval { r.stateMu.Unlock() return nil } - state := &runnerv1.TaskState{} - proto.Merge(state, r.state) + state := proto.CloneOf(r.state) r.stateChanged = false r.stateMu.Unlock() @@ -916,7 +882,7 @@ func (r *Reporter) ReportState(reportResult bool) error { return err } metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc() - r.lastReportedAtNanos.Store(time.Now().UnixNano()) + r.lastReportedAtNanos = time.Now().UnixNano() var noSent []string r.outputsMu.Lock() @@ -972,7 +938,7 @@ var stringToResult = map[string]runnerv1.Result{ "cancelled": runnerv1.Result_RESULT_CANCELLED, } -func (r *Reporter) parseResult(result any) (runnerv1.Result, bool) { +func parseResult(result any) (runnerv1.Result, bool) { str := "" if v, ok := result.(string); ok { // for jobResult str = v diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go index 1c5e0f49..219e8b9b 100644 --- a/internal/pkg/report/reporter_test.go +++ b/internal/pkg/report/reporter_test.go @@ -50,7 +50,6 @@ func TestReporter_CompositeStepResultDoesNotFinishStep(t *testing.T) { } func TestReporter_LogLevelFiltering(t *testing.T) { - // Set global level to Info so Debug entries should be filtered. origLevel := logging.GetLevel() logging.SetLevel(logging.LevelInfo) defer logging.SetLevel(origLevel) @@ -65,12 +64,9 @@ func TestReporter_LogLevelFiltering(t *testing.T) { return &runnerv1.UpdateTaskResponse{}, nil }) - ctx, cancel := context.WithCancel(t.Context()) - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") cfg.Runner.LogReportLevel = "warn" - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) assert.False(t, reporter.shouldAppendLogRow(logging.Record{Level: logging.LevelInfo})) assert.True(t, reporter.shouldAppendLogRow(logging.Record{Level: logging.LevelWarn})) reporter.RunDaemon() @@ -82,16 +78,13 @@ func TestReporter_LogLevelFiltering(t *testing.T) { dataStep0 := map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true} dataStep0Internal := map[string]any{"stage": "Main", "stepNumber": 0} - // raw_output entries always appear in job log regardless of level. require.NoError(t, fire(reporter, logEntry{Message: "step output", Data: dataStep0, Level: logging.LevelInfo})) require.NoError(t, fire(reporter, logEntry{Message: "step debug output", Data: dataStep0, Level: logging.LevelDebug})) assert.Equal(t, int64(2), reporter.state.Steps[0].LogLength, "raw_output entries must always be forwarded") - // Non-raw_output entries during steps are not added to logRows regardless of level. require.NoError(t, fire(reporter, logEntry{Message: "internal info", Data: dataStep0Internal, Level: logging.LevelInfo})) require.NoError(t, fire(reporter, logEntry{Message: "internal debug", Data: dataStep0Internal, Level: logging.LevelDebug})) - // stepResult at DebugLevel (skipped step) must still update state even when filtered from log. require.NoError(t, fire(reporter, logEntry{ Message: "Skipping step", Data: map[string]any{ @@ -105,14 +98,6 @@ func TestReporter_LogLevelFiltering(t *testing.T) { "stepResult at DebugLevel must update step state even when log entry is filtered from job log output") } -// TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from -// https://gitea.com/gitea/runner/issues/793: -// -// 1. RunDaemon calls ReportLog(false) — runner is still alive -// 2. Close() updates state to Result=FAILURE (between RunDaemon's ReportLog and ReportState) -// 3. RunDaemon's ReportState() would clone the completed state and send it, -// but the fix makes ReportState return early when closed, preventing this -// 4. Close's ReportLog(true) succeeds because the runner was not deleted func TestReporter_EphemeralRunnerDeletion(t *testing.T) { runnerDeleted := false @@ -129,7 +114,6 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) { ) client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return( func(_ context.Context, req *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) { - // Server deletes ephemeral runner when it receives a completed state if req.State != nil && req.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { runnerDeleted = true } @@ -137,25 +121,17 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) - // Fire a log entry to create pending data require.NoError(t, fire(reporter, logEntry{ Message: "build output", Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}, })) - // Step 1: RunDaemon calls ReportLog(false) — runner is still alive require.NoError(t, reporter.ReportLog(false)) - // Step 2: Close() updates state — sets Result=FAILURE and marks steps cancelled. - // In the real race, this happens while RunDaemon is between ReportLog and ReportState. reporter.stateMu.Lock() reporter.closed = true for _, v := range reporter.state.Steps { @@ -171,20 +147,13 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) { reporter.state.StoppedAt = timestamppb.Now() reporter.stateMu.Unlock() - // Step 3: RunDaemon's ReportState() — with the fix, this returns early - // because closed=true, preventing the server from deleting the runner. require.NoError(t, reporter.ReportState(false)) assert.False(t, runnerDeleted, "runner must not be deleted by RunDaemon's ReportState") - // Step 4: Close's final log upload succeeds because the runner is still alive. - // Flush pending rows first, then send the noMore signal (matching Close's retry behavior). require.NoError(t, reporter.ReportLog(false)) - // Acknowledge Close as done in daemon close(reporter.daemon) - err = reporter.ReportLog(true) - require.NoError(t, err, "final log upload must not fail: runner should not be deleted before Close finishes sending logs") - err = reporter.ReportState(true) - require.NoError(t, err, "final state update should work: runner should not be deleted before Close finishes sending logs") + require.NoError(t, reporter.ReportLog(true), "final log upload must not fail: runner should not be deleted before Close finishes sending logs") + require.NoError(t, reporter.ReportState(true), "final state update should work: runner should not be deleted before Close finishes sending logs") } func TestReporter_RunDaemonClose_Race(t *testing.T) { @@ -211,125 +180,56 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) { }, cfg) reporter.ResetSteps(newTestSteps(1)) - // Start the daemon loop — RunDaemon spawns a goroutine internally. reporter.RunDaemon() - // Close concurrently — this races with the daemon goroutine on r.closed. require.NoError(t, reporter.Close("")) - // Cancel context so the daemon goroutine exits cleanly. cancel() } -// TestReporter_MaxLatencyTimer verifies that the maxLatencyTimer flushes a -// single buffered log row before the periodic logTicker fires. -// -// Setup: logReportInterval=10s (effectively never), maxLatency=100ms. -// Fire one log line, then assert UpdateLog is called within 500ms. -func TestReporter_MaxLatencyTimer(t *testing.T) { - var updateLogCalls atomic.Int64 +func TestReporter_LogFlush(t *testing.T) { + for _, tc := range []struct { + name string + maxLatency time.Duration + batchSize int + messages []string + }{ + {"max latency timer", 100 * time.Millisecond, 1000, []string{"single log line"}}, + {"batch size", 10 * time.Second, 5, []string{"log line 0", "log line 1", "log line 2", "log line 3", "log line 4"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var updateLogCalls atomic.Int64 + client := mocks.NewClient(t) + client.On("UpdateLog", mock.Anything, mock.Anything).Return( + func(_ctx context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) { + updateLogCalls.Add(1) + return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil + }, + ) + client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil) - client := mocks.NewClient(t) - client.On("UpdateLog", mock.Anything, mock.Anything).Return( - func(_ context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) { - updateLogCalls.Add(1) - return &runnerv1.UpdateLogResponse{ - AckIndex: req.Index + int64(len(req.Rows)), - }, nil - }, - ) - client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return( - func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) { - return &runnerv1.UpdateTaskResponse{}, nil - }, - ) + cfg, _ := config.LoadDefault("") + cfg.Runner.LogReportInterval = 10 * time.Second + cfg.Runner.LogReportMaxLatency = tc.maxLatency + cfg.Runner.LogReportBatchSize = tc.batchSize + reporter := newTestReporter(t, client, cfg) + reporter.ResetSteps(newTestSteps(1)) + reporter.RunDaemon() + defer func() { require.NoError(t, reporter.Close("")) }() - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - - // Custom config: logTicker=10s (won't fire during test), maxLatency=100ms - cfg, _ := config.LoadDefault("") - cfg.Runner.LogReportInterval = 10 * time.Second - cfg.Runner.LogReportMaxLatency = 100 * time.Millisecond - cfg.Runner.LogReportBatchSize = 1000 // won't trigger batch flush - - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) - reporter.ResetSteps(newTestSteps(1)) - reporter.RunDaemon() - defer func() { - _ = reporter.Close("") - }() - - // Fire a single log line — not enough to trigger batch flush - require.NoError(t, fire(reporter, logEntry{ - Message: "single log line", - Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}, - })) - - // maxLatencyTimer should flush within ~100ms. Wait up to 500ms. - assert.Eventually(t, func() bool { - return updateLogCalls.Load() > 0 - }, 500*time.Millisecond, 10*time.Millisecond, - "maxLatencyTimer should have flushed the log before logTicker (10s)") -} - -// TestReporter_BatchSizeFlush verifies that reaching logBatchSize triggers -// an immediate log flush without waiting for any timer. -func TestReporter_BatchSizeFlush(t *testing.T) { - var updateLogCalls atomic.Int64 - - client := mocks.NewClient(t) - client.On("UpdateLog", mock.Anything, mock.Anything).Return( - func(_ context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) { - updateLogCalls.Add(1) - return &runnerv1.UpdateLogResponse{ - AckIndex: req.Index + int64(len(req.Rows)), - }, nil - }, - ) - client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return( - func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) { - return &runnerv1.UpdateTaskResponse{}, nil - }, - ) - - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - - // Custom config: large timers, small batch size - cfg, _ := config.LoadDefault("") - cfg.Runner.LogReportInterval = 10 * time.Second - cfg.Runner.LogReportMaxLatency = 10 * time.Second - cfg.Runner.LogReportBatchSize = 5 - - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) - reporter.ResetSteps(newTestSteps(1)) - reporter.RunDaemon() - defer func() { - _ = reporter.Close("") - }() - - // Fire exactly batchSize log lines - for i := range 5 { - require.NoError(t, fire(reporter, logEntry{ - Message: fmt.Sprintf("log line %d", i), - Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}, - })) + for _, message := range tc.messages { + require.NoError(t, fire(reporter, logEntry{ + Message: message, + Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}, + })) + } + assert.Eventually(t, func() bool { + return updateLogCalls.Load() > 0 + }, 500*time.Millisecond, 10*time.Millisecond, "logs must flush before the periodic timers") + }) } - - // Batch threshold should trigger immediate flush - assert.Eventually(t, func() bool { - return updateLogCalls.Load() > 0 - }, 500*time.Millisecond, 10*time.Millisecond, - "batch size threshold should have triggered immediate flush") } -// TestReporter_StateChangedNotLostDuringReport asserts that a log record arriving -// mid-UpdateTask re-dirties the flag so the change is picked up by the next report. func TestReporter_StateChangedNotLostDuringReport(t *testing.T) { var updateTaskCalls atomic.Int64 inFlight := make(chan struct{}) @@ -340,7 +240,6 @@ func TestReporter_StateChangedNotLostDuringReport(t *testing.T) { func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) { n := updateTaskCalls.Add(1) if n == 1 { - // Signal that the first UpdateTask is in flight, then block until released. close(inFlight) <-release } @@ -348,53 +247,38 @@ func TestReporter_StateChangedNotLostDuringReport(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(2)) - // Mark stateChanged=true so the first ReportState proceeds to UpdateTask. reporter.stateMu.Lock() reporter.stateChanged = true reporter.stateMu.Unlock() - // Kick off the first ReportState in a goroutine — it will block in UpdateTask. done := make(chan error, 1) go func() { done <- reporter.ReportState(false) }() - // Wait until UpdateTask is in flight (snapshot taken, flag consumed). <-inFlight - // A concurrent log record modifies state — must re-flip stateChanged so the - // change is not lost when the in-flight ReportState finishes. require.NoError(t, fire(reporter, logEntry{ Message: "step starts", Data: map[string]any{"stage": "Main", "stepNumber": 1, "raw_output": true}, })) - // Release the in-flight UpdateTask and wait for it to return. close(release) require.NoError(t, <-done) - // stateChanged must still be true so the next ReportState picks up the - // concurrent record's change instead of skipping via the early-return path. reporter.stateMu.RLock() changed := reporter.stateChanged reporter.stateMu.RUnlock() assert.True(t, changed, "stateChanged must remain true after a concurrent log record during in-flight ReportState") - // And the next ReportState must actually send a second UpdateTask. require.NoError(t, reporter.ReportState(false)) assert.Equal(t, int64(2), updateTaskCalls.Load(), "concurrent log record change must trigger a second UpdateTask, not be silently lost") } -// TestReporter_StateChangedRestoredOnError verifies that when UpdateTask fails, -// the dirty flag is restored so the snapshotted change isn't silently lost. func TestReporter_StateChangedRestoredOnError(t *testing.T) { var updateTaskCalls atomic.Int64 @@ -409,19 +293,14 @@ func TestReporter_StateChangedRestoredOnError(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) reporter.stateMu.Lock() reporter.stateChanged = true reporter.stateMu.Unlock() - // First ReportState fails — flag must be restored to true. require.Error(t, reporter.ReportState(false)) reporter.stateMu.RLock() @@ -429,13 +308,10 @@ func TestReporter_StateChangedRestoredOnError(t *testing.T) { reporter.stateMu.RUnlock() assert.True(t, changed, "stateChanged must be restored to true after UpdateTask error so the change is retried") - // The next ReportState should still issue a request because the flag was restored. require.NoError(t, reporter.ReportState(false)) assert.Equal(t, int64(2), updateTaskCalls.Load()) } -// TestReporter_StateNotifyFlush verifies that step transitions trigger -// an immediate state flush via the stateNotify channel. func TestReporter_StateNotifyFlush(t *testing.T) { var updateTaskCalls atomic.Int64 @@ -454,41 +330,28 @@ func TestReporter_StateNotifyFlush(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - - // Custom config: large state interval so only stateNotify can trigger cfg, _ := config.LoadDefault("") cfg.Runner.StateReportInterval = 10 * time.Second cfg.Runner.LogReportInterval = 10 * time.Second - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) reporter.RunDaemon() defer func() { _ = reporter.Close("") }() - // Fire a log entry that starts a step — this triggers stateNotify require.NoError(t, fire(reporter, logEntry{ Message: "step starting", Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}, })) - // stateNotify should trigger immediate UpdateTask call assert.Eventually(t, func() bool { return updateTaskCalls.Load() > 0 }, 500*time.Millisecond, 10*time.Millisecond, "step transition should have triggered immediate state flush via stateNotify") } -// Regression test for https://gitea.com/gitea/runner/issues/950: Close() must -// always send a final UpdateLog with NoMore=true carrying at least one row, -// otherwise the server's len(Rows)==0 short-circuit skips TransferLogs. -// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all -// supported branches, e.g. v1.28+. func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) { var lastReq atomic.Pointer[runnerv1.UpdateLogRequest] var noMoreCalls atomic.Int64 @@ -511,25 +374,16 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - - // Intervals large enough that no daemon-driven flush fires during the test. cfg, _ := config.LoadDefault("") cfg.Runner.LogReportInterval = 10 * time.Second cfg.Runner.LogReportMaxLatency = 10 * time.Second cfg.Runner.StateReportInterval = 10 * time.Second cfg.Runner.LogReportBatchSize = 1000 - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) reporter.RunDaemon() - // Simulate a successful job whose log buffer was already drained by the - // daemon (logOffset > 0, logRows empty, terminal Result set). This is the - // state Close() lands in for the typical successful job under #819. reporter.stateMu.Lock() reporter.logOffset = 5 reporter.state.Result = runnerv1.Result_RESULT_SUCCESS @@ -546,11 +400,6 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) { assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row") } -// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat -// UpdateTask once stateReportInterval has elapsed since the last successful -// report, even when nothing has changed. Without this, long-running silent -// jobs (no log output, no step transitions) cause the server to time the -// task out and cancel it (#826). func TestReporter_StateHeartbeat(t *testing.T) { var updateTaskCalls atomic.Int64 @@ -562,39 +411,25 @@ func TestReporter_StateHeartbeat(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") cfg.Runner.StateReportInterval = 50 * time.Millisecond - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) - // First call has no prior report — sends to seed lastReportedAt. reporter.stateMu.Lock() reporter.stateChanged = true reporter.stateMu.Unlock() require.NoError(t, reporter.ReportState(false)) require.Equal(t, int64(1), updateTaskCalls.Load()) - // Second call immediately after with nothing changed — must skip. require.NoError(t, reporter.ReportState(false)) assert.Equal(t, int64(1), updateTaskCalls.Load(), "no-op ReportState within stateReportInterval must skip") - // After stateReportInterval elapses, a heartbeat must fire even with no changes. time.Sleep(2 * cfg.Runner.StateReportInterval) require.NoError(t, reporter.ReportState(false)) assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change") } -// TestReporter_ServerCancelStillFlushesFinal asserts that when the Gitea server -// returns RESULT_CANCELLED on an in-flight UpdateTask (which causes the -// reporter to cancel the task context), Close() still successfully sends the -// final UpdateLog{NoMore:true} and the final UpdateTask carrying the populated -// final state. Before the fix this final flush used r.ctx, which was just -// cancelled, so retry-go aborted on its context check and Gitea never received -// the runner's acknowledgement of the cancel. func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) { var ( updateTaskCalls atomic.Int64 @@ -613,10 +448,6 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) { }, nil }, ) - // The first UpdateTask returns RESULT_CANCELLED — modelling a server-side - // cancellation; the reporter must call r.cancel() in response. The final - // UpdateTask issued by Close() must still arrive even though r.ctx is now - // cancelled. client.On("UpdateTask", mock.Anything, mock.Anything).Return( func(_ context.Context, req *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) { n := updateTaskCalls.Add(1) @@ -642,42 +473,27 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) { reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) reporter.ResetSteps(newTestSteps(1)) - // Force the first ReportState to actually call UpdateTask. reporter.stateMu.Lock() reporter.stateChanged = true reporter.stateMu.Unlock() - // First ReportState — server returns RESULT_CANCELLED, reporter cancels r.ctx. require.NoError(t, reporter.ReportState(false)) require.Equal(t, int64(1), updateTaskCalls.Load()) select { case <-ctx.Done(): - // Expected: reporter called cancel() because the server reported the task as cancelled. case <-time.After(time.Second): t.Fatal("expected r.ctx to be cancelled after server returned RESULT_CANCELLED") } - // The test does not start the daemon goroutine; close(r.daemon) so Close() - // proceeds without waiting on its 60s timeout. close(reporter.daemon) - // Now Close() runs. Before the fix, both final RPCs aborted on the cancelled - // r.ctx via retry.Context. After the fix, Close() uses a detached context and - // the per-RPC rpcCtx() falls back to a fresh ctx, so both calls succeed. require.NoError(t, reporter.Close("cancelled")) assert.True(t, finalLogNoMoreSeen.Load(), "Close() must send a final UpdateLog{NoMore:true} even after server-side cancellation") assert.True(t, finalTaskStateSeen.Load(), "Close() must send a final UpdateTask with the populated final state even after server-side cancellation") } -// TestReporter_CloseReportsCancelledOnCanceledCtx asserts that when Close() -// runs on a reporter whose state has not been finalised AND whose context has -// been cancelled, the synthesised final state carries RESULT_CANCELLED and -// the appended log row reads "Cancelled" — not RESULT_FAILURE / "Early -// termination". This is the runner-side half of the Running -> Cancelling -> -// Cancelled flow: it gives Gitea an explicit cancel acknowledgement rather -// than a generic failure when the job is torn down on the cancel path. func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) { var finalState atomic.Pointer[runnerv1.TaskState] var finalLogRows atomic.Pointer[[]*runnerv1.LogRow] @@ -710,13 +526,10 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) { reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) reporter.ResetSteps(newTestSteps(1)) - // Simulate the cancellation path: r.ctx is cancelled before Close() runs. cancel() - // Skip the daemon wait inside Close(). close(reporter.daemon) - // Empty lastWords so Close() picks the synthesised value. require.NoError(t, reporter.Close("")) got := finalState.Load() @@ -742,8 +555,6 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) { assert.False(t, foundEarlyTermination, "final log must not contain 'Early termination' on the cancel path") } -// TestReporter_StopHeartbeats verifies that StopHeartbeats ends periodic -// UpdateTask heartbeats while Close() still flushes the final state. func TestReporter_StopHeartbeats(t *testing.T) { var updateTaskCalls atomic.Int64 @@ -762,17 +573,12 @@ func TestReporter_StopHeartbeats(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - cfg, err := config.LoadDefault("") require.NoError(t, err) cfg.Runner.StateReportInterval = 20 * time.Millisecond cfg.Runner.LogReportInterval = time.Hour - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) reporter.RunDaemon() @@ -810,7 +616,6 @@ func TestAppendLogRow(t *testing.T) { require.Len(t, r.logRows, 1) assert.Equal(t, "hello ***", r.logRows[0].Content) - // repairing the invalid byte spells out the secret, so the repair has to come first r = &Reporter{logReplacer: strings.NewReplacer("a?b", "***")} r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: "a\xffb"}) assert.Equal(t, "***", r.logRows[0].Content) @@ -835,34 +640,28 @@ func TestReporter_SetOutputs(t *testing.T) { require.True(t, ok) assert.Equal(t, "bar", got.value) - // first value wins: a later write to the same key is ignored r.SetOutputs(map[string]string{"foo": "baz"}) assert.Equal(t, "bar", r.outputs["foo"].value) - // keys longer than maxOutputKeyLen are dropped longKey := strings.Repeat("k", maxOutputKeyLen+1) r.SetOutputs(map[string]string{longKey: "v"}) _, ok = r.outputs[longKey] assert.False(t, ok) - // values longer than maxOutputValueLen are dropped longValue := strings.Repeat("v", maxOutputValueLen+1) r.SetOutputs(map[string]string{"big": longValue}) _, ok = r.outputs["big"] assert.False(t, ok) - // a value carrying a secret is skipped, as GitHub does, rather than sent masked r.SetOutputs(map[string]string{"leaky": "has s3cr3t in it"}) _, ok = r.outputs["leaky"] assert.False(t, ok) - // invalid UTF-8 is not a secret, so the value is kept as it is rather than dropped r.SetOutputs(map[string]string{"binary": "caf\xff"}) got, ok = r.outputs["binary"] require.True(t, ok) assert.Equal(t, "caf\xff", got.value) - // a value at exactly the limit is still stored maxValue := strings.Repeat("v", maxOutputValueLen) r.SetOutputs(map[string]string{"atlimit": maxValue}) got, ok = r.outputs["atlimit"] @@ -870,8 +669,6 @@ func TestReporter_SetOutputs(t *testing.T) { assert.Len(t, got.value, maxOutputValueLen) } -// Gitea delivers ACTIONS_STEP_DEBUG as a secret, so masking "true" would drop any job output -// saying it. GitHub skips the same two keys. func TestReporter_DebugSettingsAreNotMasked(t *testing.T) { taskCtx, err := structpb.NewStruct(map[string]any{}) require.NoError(t, err) @@ -886,10 +683,9 @@ func TestReporter_DebugSettingsAreNotMasked(t *testing.T) { assert.Equal(t, "***", reporter.mask("s3cr3t")) reporter.SetOutputs(map[string]string{"changed": "true"}) - assert.Equal(t, "true", reporter.outputs["changed"].value) // needs..outputs.changed == 'true' still works + assert.Equal(t, "true", reporter.outputs["changed"].value) } -// An output the server acknowledged is not reported again. func TestReporter_OutputsSentOnce(t *testing.T) { client := mocks.NewClient(t) var reported []map[string]string @@ -899,12 +695,8 @@ func TestReporter_OutputsSentOnce(t *testing.T) { return &runnerv1.UpdateTaskResponse{SentOutputs: slices.Collect(maps.Keys(req.Outputs))}, nil }) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") - r := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + r := newTestReporter(t, client, cfg) r.SetOutputs(map[string]string{"foo": "bar"}) require.NoError(t, r.ReportState(false)) @@ -935,8 +727,6 @@ func TestReporter_FlushFinalRetriesUntilSuccess(t *testing.T) { } func TestReporter_ParseResult(t *testing.T) { - r := &Reporter{} - tests := []struct { name string input any @@ -951,15 +741,13 @@ func TestReporter_ParseResult(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := r.parseResult(tt.input) + got, ok := parseResult(tt.input) assert.Equal(t, tt.wantOk, ok) assert.Equal(t, tt.want, got) }) } } -// A secret leaked in an encoded form — the shape it takes once an action puts it in a -// JSON body, a URL or a base64 payload — must be masked in the reported log as well. func TestReporter_masksEncodedSecrets(t *testing.T) { secret := `p@ss w"rd/1` r := &Reporter{logReplacer: strings.NewReplacer()} @@ -978,20 +766,14 @@ func TestReporter_masksEncodedSecrets(t *testing.T) { } } -// A server that acknowledges more rows than were sent must not take the runner -// process down with it. func TestReporter_AckIndexBeyondBuffer(t *testing.T) { client := mocks.NewClient(t) client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, _ *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) { return &runnerv1.UpdateLogResponse{AckIndex: 1000}, nil }) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) require.NoError(t, fire(reporter, logEntry{ @@ -1003,7 +785,6 @@ func TestReporter_AckIndexBeyondBuffer(t *testing.T) { require.NoError(t, reporter.ReportLog(false)) } -// Close has to survive giving up on a daemon still parked in an RPC. func TestReporter_CloseWithStuckDaemon(t *testing.T) { release := make(chan struct{}) entered := make(chan struct{}) @@ -1023,13 +804,9 @@ func TestReporter_CloseWithStuckDaemon(t *testing.T) { return &runnerv1.UpdateTaskResponse{}, nil }).Maybe() - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) cfg, _ := config.LoadDefault("") cfg.Runner.LogReportInterval = 10 * time.Millisecond - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.daemonWait = time.Millisecond reporter.RunDaemon() reporter.ResetSteps(newTestSteps(1)) @@ -1053,8 +830,6 @@ func TestReporter_CloseWithStuckDaemon(t *testing.T) { require.NoError(t, reporter.Close("")) } -// The zombie reaper marks a task failed, and cancelling the context is how the -// runner stops, so the two must not be conflated into "cancelled". func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) { var lastState *runnerv1.TaskState client := mocks.NewClient(t) @@ -1076,7 +851,7 @@ func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) { require.NoError(t, err) cfg, _ := config.LoadDefault("") reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) - close(reporter.daemon) // no daemon loop to acknowledge + close(reporter.daemon) require.NoError(t, reporter.ReportState(false)) require.ErrorIs(t, ctx.Err(), context.Canceled) @@ -1091,11 +866,9 @@ func TestReporter_NoteReport(t *testing.T) { reporter := &Reporter{reportFailing: map[string]bool{}} - // A pending output is not a transport failure and must not read as one. reporter.noteReport(metrics.LabelMethodUpdateTask, fmt.Errorf("wrapped: %w", errOutputsNotSent)) assert.Empty(t, hook.AllRecords()) - // An outage logs once at each end, not every report interval for hours. reporter.noteReport(metrics.LabelMethodUpdateTask, errors.New("connection refused")) reporter.noteReport(metrics.LabelMethodUpdateTask, errors.New("connection refused")) require.Len(t, hook.AllRecords(), 1) @@ -1106,8 +879,6 @@ func TestReporter_NoteReport(t *testing.T) { assert.Contains(t, hook.LastRecord().Message, "reconnected") } -// A job's final error can carry a secret, e.g. one interpolated into a failing -// expression, so Close must mask it like every other row. func TestReporter_CloseMasksLastWords(t *testing.T) { const secret = "supersecret" var rows []*runnerv1.LogRow @@ -1145,9 +916,6 @@ func TestReporter_CloseMasksLastWords(t *testing.T) { assert.Equal(t, "could not get job matrix: *** ***", rows[0].Content) } -// giteaLogModel mirrors how Gitea stores a task log: UpdateLog appends rows to one -// stream, and UpdateTask overwrites the per-step ranges the web UI slices that stream by -// (modules/actions/task_state.go, FullSteps). type giteaLogModel struct { mu sync.Mutex rows int64 @@ -1169,15 +937,12 @@ func (m *giteaLogModel) applyState(state *runnerv1.TaskState) { } } -// snapshot returns the accepted rows and how far the reported step ranges reach into -// them. The remainder is what the web UI renders under "Complete job". func (m *giteaLogModel) snapshot() (rows, claimed int64) { m.mu.Lock() defer m.mu.Unlock() return m.rows, m.claimed } -// Regression test for https://gitea.com/gitea/runner/issues/1184. func TestReporter_StepRangeCoversAckedRows(t *testing.T) { server := &giteaLogModel{} @@ -1196,20 +961,13 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) { }, ) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - - // Only the batch threshold may flush: the tickers are the periodic repair this test - // must not depend on, and Close() is the one after the damage is done. cfg, _ := config.LoadDefault("") cfg.Runner.LogReportInterval = time.Hour cfg.Runner.LogReportMaxLatency = time.Hour cfg.Runner.StateReportInterval = time.Hour cfg.Runner.LogReportBatchSize = 5 - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) reporter.ResetSteps(newTestSteps(1)) reporter.RunDaemon() defer func() { @@ -1218,7 +976,6 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) { stepData := map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true} - // The step's first row is a transition, so it flushes state as well as logs. require.NoError(t, fire(reporter, logEntry{Message: "step starting", Data: stepData})) require.Eventually(t, func() bool { rows, claimed := server.snapshot() @@ -1239,14 +996,7 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) { "every log row the server accepted must be covered by a reported step range") } -// A flush the server took no rows for describes nothing new, so it must not spend an -// UpdateTask. UpdateTask is never registered on the client, so calling it fails the test. func TestReporter_FlushWithoutAckedRowsSkipsState(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - taskCtx, err := structpb.NewStruct(map[string]any{}) - require.NoError(t, err) - client := mocks.NewClient(t) client.On("UpdateLog", mock.Anything, mock.Anything).Once().Return( func(_ context.Context, _ *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) { @@ -1255,16 +1005,23 @@ func TestReporter_FlushWithoutAckedRowsSkipsState(t *testing.T) { ) cfg, _ := config.LoadDefault("") - reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter := newTestReporter(t, client, cfg) - // An idle job: no rows, so not even an UpdateLog. reporter.reportLogWithState() - // A row the server declines to take: an UpdateLog, but still no step range to report. reporter.Logf("row") reporter.reportLogWithState() } +func newTestReporter(t *testing.T, client *mocks.Client, cfg *config.Config) *Reporter { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + taskCtx, err := structpb.NewStruct(map[string]any{}) + require.NoError(t, err) + return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) +} + func newTestSteps(n int) []*model.Step { steps := make([]*model.Step, n) for i := range steps { @@ -1353,7 +1110,7 @@ func TestReporter_ReportedSteps(t *testing.T) { {main, 0, "Echo 1", success, 6, 2}, {main, 1, "Run echo 1", skipped, 0, 0}, {post, 0, "Post Echo 1", success, 8, 3}, - {cleanup, 0, "Complete job", unspecified, 11, 2}, // still logging after the job result + {cleanup, 0, "Complete job", unspecified, 11, 2}, } assert.Equal(t, want, reportedOf(t, reporter)) @@ -1362,7 +1119,6 @@ func TestReporter_ReportedSteps(t *testing.T) { want[5].result, want[5].logLen = success, 3 assert.Equal(t, want, reportedOf(t, reporter)) - // the workflow steps keep what Gitea versions deriving them from the workflow file expect require.Len(t, reporter.state.Steps, 2) assert.EqualValues(t, 6, reporter.state.Steps[0].LogIndex) assert.EqualValues(t, 2, reporter.state.Steps[0].LogLength) @@ -1403,7 +1159,7 @@ func TestReporter_ReportedSteps(t *testing.T) { "Set up job": success, "Pre Run echo 0": success, "Pre Run echo 2": success, - "Run echo 0": failure, // not the result of its composite step + "Run echo 0": failure, "Run echo 1": cancelled, "Run echo 2": cancelled, "Post Run echo 0": failure, @@ -1473,7 +1229,6 @@ func TestReporter_ReportedSteps(t *testing.T) { }) } -// logEntry is the record a job logger hands the Reporter, with the fields it accumulated as Data. type logEntry struct { Message string Data map[string]any