fix: job container, daemon startup, action cache and exec fixes (#1294)

- Keep `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_STATE` and `GITHUB_PATH` when a job times out or is cancelled, so post steps still get their state
- Retry the startup Declare while Gitea is unreachable instead of exiting, still failing fast on rejected registrations, auth and misconfiguration
- Reuse a local image when a platform variant such as `linux/arm/v7` is requested instead of pulling again
- Read `PATH` from the created job container so cross-architecture jobs get tool paths for the right architecture
- Extract action copies relative to the act volume so they never cross an image's `/var/run` symlink, which rootless Podman rejects
- Keep the job container's entrypoint when container options set `--entrypoint`, warning instead of appending it to the keep-alive command
- Lock action cache entries across runner processes that share a cache directory
- `exec`: omit `GITHUB_SERVER_URL` and `GITHUB_API_URL` without `--gitea-instance` instead of exporting a bare `https://`, and read owner and repository from any origin remote

Reviewed-on: https://gitea.com/gitea/runner/pulls/1294
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and bircni committed 2026-10-10 18:11:03 +00:00
1 parent b741335222
commit ad9eeb668a
23 files changed
+537 -308

No files matched your search

+53 -30
View File
@@ -10,8 +10,10 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
@@ -29,8 +31,6 @@ import (
var ( var (
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`) codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`) codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`) hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
@@ -44,12 +44,29 @@ var (
ErrShortRef = errors.New("short SHA references are not supported") ErrShortRef = errors.New("short SHA references are not supported")
) )
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir. // AcquireCloneLock serializes access to dir across runner processes, or within this one where no lock file can be created. Readers of dir must hold it too.
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel. func AcquireCloneLock(ctx context.Context, dir string) (func(), error) {
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too, dir = filepath.Clean(dir)
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read. if runtime.GOOS == "plan9" {
func AcquireCloneLock(dir string) func() { return cloneLocks.Lock(dir), nil
return cloneLocks.Lock(dir) }
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return nil, err
}
for {
release, err := lock.TryLock(dir)
if err == nil {
return func() { _ = release() }, nil
}
if !errors.Is(err, lock.ErrLocked) {
return cloneLocks.Lock(dir), nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
} }
type Error struct { type Error struct {
@@ -109,13 +126,12 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
} }
// FindGithubRepo get the repo // FindGithubRepo get the repo
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) { func FindGithubRepo(ctx context.Context, file string) (string, error) {
url, err := findGitRemoteURL(ctx, file) url, err := findGitRemoteURL(ctx, file)
if err != nil { if err != nil {
return "", err return "", err
} }
_, slug := findGitSlug(url, githubInstance) return findGitSlug(url)
return slug, nil
} }
func findGitRemoteURL(ctx context.Context, file string) (string, error) { func findGitRemoteURL(ctx context.Context, file string) (string, error) {
@@ -130,25 +146,25 @@ func findGitRemoteURL(ctx context.Context, file string) (string, error) {
return url, nil return url, nil
} }
func findGitSlug(url, githubInstance string) (string, string) { func findGitSlug(remoteURL string) (string, error) {
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil { if matches := codeCommitHTTPRegex.FindStringSubmatch(remoteURL); matches != nil {
return "CodeCommit", matches[2] return matches[2], nil
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil { } else if matches := codeCommitSSHRegex.FindStringSubmatch(remoteURL); matches != nil {
return "CodeCommit", matches[2] return matches[2], nil
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if githubInstance != "github.com" {
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
}
} }
return "", url if host, repoPath, ok := strings.Cut(remoteURL, ":"); ok && filepath.VolumeName(remoteURL) == "" && !strings.Contains(host, "/") && !strings.HasPrefix(repoPath, "//") {
remoteURL = "ssh://" + host + "/" + repoPath // scp-like [user@]host:path
}
parsed, err := url.Parse(remoteURL)
if err != nil {
return "", errors.New("cannot parse the origin remote URL")
}
owner, repo := path.Split(strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git"))
owner = path.Base(owner)
if parsed.Host == "" || repo == "" || owner == "." || owner == "/" {
return "", errors.New("cannot determine owner and repository from the origin remote URL")
}
return owner + "/" + repo, nil
} }
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor // NewGitCloneExecutorInput the input for the NewGitCloneExecutor
@@ -182,6 +198,9 @@ func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger
if err == nil && origin == input.URL { if err == nil && origin == input.URL {
return true, nil return true, nil
} }
if ctxErr := ctx.Err(); ctxErr != nil { // a cancelled origin lookup must not discard a valid clone
return false, ctxErr
}
if err == nil { if err == nil {
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL) logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL)
} else if _, statErr := os.Stat(input.Dir); statErr == nil { } else if _, statErr := os.Stat(input.Dir); statErr == nil {
@@ -209,7 +228,11 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref) logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
} }
logger.Debugf(" cloning %s to %s", input.URL, input.Dir) logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer AcquireCloneLock(input.Dir)() unlock, err := AcquireCloneLock(ctx, input.Dir)
if err != nil {
return err
}
defer unlock()
reused, err := CloneIfRequired(ctx, input, logger) reused, err := CloneIfRequired(ctx, input, logger)
if err != nil { if err != nil {
+71 -27
View File
@@ -18,9 +18,11 @@ import (
"sync" "sync"
"syscall" "syscall"
"testing" "testing"
"testing/synctest"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/lock"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
@@ -29,29 +31,33 @@ import (
) )
func TestFindGitSlug(t *testing.T) { func TestFindGitSlug(t *testing.T) {
assert := assert.New(t) for _, testcase := range []struct {
remoteURL string
slugTests := []struct { slug string
url string // input
provider string // expected result
slug string // expected result
}{ }{
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"}, {"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "my-repo-name"},
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"}, {"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "my-repo"},
{"[email protected]:nektos/act.git", "GitHub", "nektos/act"}, {"[email protected]:nektos/act.git", "nektos/act"},
{"[email protected]:nektos/act", "GitHub", "nektos/act"}, {"[email protected]:nektos/act", "nektos/act"},
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"}, {"https://github.com/nektos/act.git", "nektos/act"},
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"}, {"http://github.com/nektos/act.git", "nektos/act"},
{"https://github.com/nektos/act", "GitHub", "nektos/act"}, {"https://github.com/nektos/act", "nektos/act"},
{"http://github.com/nektos/act", "GitHub", "nektos/act"}, {"http://github.com/nektos/act", "nektos/act"},
{"git+ssh://[email protected]/owner/repo.git", "GitHub", "owner/repo"}, {"git+ssh://[email protected]/owner/repo.git", "owner/repo"},
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"}, {"https://example.com:3000/gitea/owner/repo.git/", "owner/repo"},
} {"ssh://[email protected]:2222/gitea/owner/repo.git", "owner/repo"},
{"example.com:owner/repo.git", "owner/repo"},
for _, tt := range slugTests { {"http://myotherrepo.com/act.git", ""},
provider, slug := findGitSlug(tt.url, "github.com") {"https://user:[email protected]/act.git", ""},
assert.Equal(tt.provider, provider) {"/tmp/owner/repo.git", ""},
assert.Equal(tt.slug, slug) {"./owner/repo:branch", ""},
} {
t.Run(testcase.remoteURL, func(t *testing.T) {
slug, err := findGitSlug(testcase.remoteURL)
assert.Equal(t, testcase.slug == "", err != nil)
assert.NotContains(t, fmt.Sprint(err), "secret")
assert.Equal(t, testcase.slug, slug)
})
} }
} }
@@ -107,7 +113,7 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, remoteURL, url) require.Equal(t, remoteURL, url)
slug, err := FindGithubRepo(context.Background(), basedir, "github.com") slug, err := FindGithubRepo(context.Background(), basedir)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "owner/repo", slug) require.Equal(t, "owner/repo", slug)
} }
@@ -310,6 +316,12 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
markerPath := filepath.Join(cacheDir, "stale-marker") markerPath := filepath.Join(cacheDir, "stale-marker")
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644)) require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
cancelled, cancel := context.WithCancel(t.Context())
cancel()
_, err := CloneIfRequired(cancelled, NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: cacheDir}, log.New())
require.ErrorIs(t, err, context.Canceled)
require.FileExists(t, markerPath)
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: newRemoteDir, URL: newRemoteDir,
Ref: "main", Ref: "main",
@@ -631,14 +643,42 @@ func gitCmd(args ...string) error {
} }
func TestAcquireCloneLock(t *testing.T) { func TestAcquireCloneLock(t *testing.T) {
t.Run("blocks on a lock held by another process despite a trailing separator", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "cache")
release, err := lock.TryLock(dir)
require.NoError(t, err)
defer func() { require.NoError(t, release()) }()
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
_, err := AcquireCloneLock(ctx, dir+string(filepath.Separator))
require.ErrorIs(t, err, context.DeadlineExceeded)
})
})
t.Run("read-only parent falls back to an in-process lock", func(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "cache")
require.NoError(t, os.Mkdir(dir, 0o755))
require.NoError(t, os.Chmod(parent, 0o555))
defer func() { require.NoError(t, os.Chmod(parent, 0o755)) }()
unlock, err := AcquireCloneLock(t.Context(), dir)
require.NoError(t, err)
unlock()
})
t.Run("same directory serializes", func(t *testing.T) { t.Run("same directory serializes", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
unlock1 := AcquireCloneLock(dir) unlock1, err := AcquireCloneLock(t.Context(), dir)
require.NoError(t, err)
secondAcquired := make(chan struct{}) secondAcquired := make(chan struct{})
go func() { go func() {
unlock := AcquireCloneLock(dir) unlock, err := AcquireCloneLock(t.Context(), dir)
if !assert.NoError(t, err) {
return
}
close(secondAcquired) close(secondAcquired)
unlock() unlock()
}() }()
@@ -662,12 +702,16 @@ func TestAcquireCloneLock(t *testing.T) {
dirA := t.TempDir() dirA := t.TempDir()
dirB := t.TempDir() dirB := t.TempDir()
unlockA := AcquireCloneLock(dirA) unlockA, err := AcquireCloneLock(t.Context(), dirA)
require.NoError(t, err)
defer unlockA() defer unlockA()
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
unlock := AcquireCloneLock(dirB) unlock, err := AcquireCloneLock(t.Context(), dirB)
if !assert.NoError(t, err) {
return
}
unlock() unlock()
close(done) close(done)
}() }()
+15 -3
View File
@@ -8,10 +8,10 @@ package container
import ( import (
"context" "context"
"fmt"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client" "github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1"
) )
// ImageExistsLocally returns a boolean indicating if an image with the // ImageExistsLocally returns a boolean indicating if an image with the
@@ -30,11 +30,23 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
return false, err return false, err
} }
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform { if platform == "" || platform == "any" {
return true, nil return true, nil
} }
return false, nil requested, err := parsePlatform(platform)
if err != nil {
return false, err
}
return platformMatches(requested, &specs.Platform{OS: inspectImage.Os, Architecture: inspectImage.Architecture, Variant: inspectImage.Variant}), nil
}
func platformMatches(requested, image *specs.Platform) bool {
variant := image.Variant
if variant == "" {
variant = map[string]string{"amd64": "v1", "arm": "v7", "arm64": "v8"}[image.Architecture]
}
return image.OS == requested.OS && image.Architecture == requested.Architecture && (requested.Variant == "" || requested.Variant == variant)
} }
// RemoveImage removes image from local store, the function is used to run different // RemoveImage removes image from local store, the function is used to run different
+23
View File
@@ -61,3 +61,26 @@ func TestParsePlatform(t *testing.T) {
}) })
} }
} }
func TestPlatformMatches(t *testing.T) {
for _, testcase := range []struct {
requested, image string
want bool
}{
{"linux/amd64", "linux/amd64", true},
{"linux/amd64/v1", "linux/amd64", true},
{"linux/arm64", "linux/amd64", false},
{"linux/arm64/v8", "linux/arm64", true},
{"linux/arm/v7", "linux/arm/v7", true},
{"linux/arm", "linux/arm/v7", true},
{"linux/arm/v6", "linux/arm/v7", false},
{"linux/arm", "linux/arm/v6", true},
{"windows/arm/v7", "linux/arm/v7", false},
} {
requested, err := parsePlatform(testcase.requested)
require.NoError(t, err)
image, err := parsePlatform(testcase.image)
require.NoError(t, err)
assert.Equal(t, testcase.want, platformMatches(requested, image), "%s on %s", testcase.requested, testcase.image)
}
}
+47 -99
View File
@@ -31,7 +31,7 @@ import (
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper"
"github.com/joho/godotenv" "github.com/docker/cli/opts"
"github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
@@ -95,8 +95,8 @@ func (cr *containerReference) Start(attach bool) common.Executor {
cr.attach().IfBool(attach), cr.attach().IfBool(attach),
cr.start(), cr.start(),
cr.wait().IfBool(attach), cr.wait().IfBool(attach),
cr.tryReadUID(), cr.tryReadID("-u", func(id int) { cr.UID = id }),
cr.tryReadGID(), cr.tryReadID("-g", func(id int) { cr.GID = id }),
func(ctx context.Context) error { func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container // If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 { if cr.UID != 0 || cr.GID != 0 {
@@ -305,7 +305,25 @@ func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]stri
} }
func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor { func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor {
return cr.extractFromImageEnv(env).IfNot(common.Dryrun) envMap := *env
return common.Executor(func(ctx context.Context) error {
inspect, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
if err != nil {
common.Logger(ctx).Error(err)
return fmt.Errorf("inspect container: %w", err)
}
if inspect.Container.Config == nil {
return nil
}
for key, value := range opts.ConvertKVStringsToMap(inspect.Container.Config.Env) {
if envMap[key] == "" {
envMap[key] = value
} else if key == "PATH" {
envMap[key] += ":" + value
}
}
return nil
}).IfNot(common.Dryrun)
} }
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor { func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
@@ -567,20 +585,6 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
} }
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
// In the old fork version, the code is
// if len(copts.netMode.Value()) == 0 {
// if err = copts.netMode.Set("host"); err != nil {
// return nil, nil, fmt.Errorf("cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
// }
// }
// And it has been commented with:
// If a service container's network is set to `host`, the container will not be able to
// connect to the specified network created for the job container and the service containers.
// So comment out the following code.
// Not the if it's necessary to comment it in the new version,
// since it's cr.input.NetworkMode now.
if len(copts.netMode.Value()) == 0 { if len(copts.netMode.Value()) == 0 {
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil { 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 nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
@@ -644,6 +648,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
if flags.Changed("network") || flags.Changed("net") { if flags.Changed("network") || flags.Changed("net") {
logger.Warn("--network and --net in the options will be ignored.") logger.Warn("--network and --net in the options will be ignored.")
} }
if len(cr.input.Entrypoint) != 0 && flags.Changed("entrypoint") {
logger.Warn("--entrypoint in the options will be ignored.")
}
hostConfig.NetworkMode = networkMode hostConfig.NetworkMode = networkMode
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig) logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
@@ -673,17 +680,11 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
Tty: input.AllocatePTY, Tty: input.AllocatePTY,
} }
// For Gitea, reduce log noise
// logger.Debugf("Common container.Config ==> %+v", config)
if len(input.Cmd) != 0 { if len(input.Cmd) != 0 {
config.Cmd = input.Cmd config.Cmd = input.Cmd
} }
if len(input.Entrypoint) != 0 {
config.Entrypoint = input.Entrypoint
}
mounts := make([]mount.Mount, 0) mounts := make([]mount.Mount, 0)
for mountSource, mountTarget := range input.Mounts { for mountSource, mountTarget := range input.Mounts {
mounts = append(mounts, mount.Mount{ mounts = append(mounts, mount.Mount{
@@ -718,20 +719,20 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
PortBindings: portBindings, PortBindings: portBindings,
AutoRemove: input.AutoRemove, AutoRemove: input.AutoRemove,
} }
// For Gitea, reduce log noise
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig) config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
if err != nil { if err != nil {
return err return err
} }
if len(input.Entrypoint) != 0 {
config.Entrypoint = input.Entrypoint
}
// For Gitea // For Gitea
config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig) config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig)
var networkingConfig *network.NetworkingConfig var networkingConfig *network.NetworkingConfig
// For Gitea, reduce log noise
// logger.Debugf("input.NetworkAliases ==> %v", input.NetworkAliases)
n := hostConfig.NetworkMode n := hostConfig.NetworkMode
// IsUserDefined and IsHost are broken on windows // IsUserDefined and IsHost are broken on windows
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 { if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
@@ -764,44 +765,6 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
} }
} }
func (cr *containerReference) extractFromImageEnv(env *map[string]string) common.Executor {
envMap := *env
return func(ctx context.Context) error {
logger := common.Logger(ctx)
inspect, err := cr.cli.ImageInspect(ctx, cr.input.Image)
if err != nil {
logger.Error(err)
return fmt.Errorf("inspect image: %w", err)
}
if inspect.Config == nil {
return nil
}
imageEnv, err := godotenv.Unmarshal(strings.Join(inspect.Config.Env, "\n"))
if err != nil {
logger.Error(err)
return fmt.Errorf("unmarshal image env: %w", err)
}
for k, v := range imageEnv {
if k == "PATH" {
if envMap[k] == "" {
envMap[k] = v
} else {
envMap[k] += `:` + v
}
} else if envMap[k] == "" {
envMap[k] = v
}
}
env = &envMap
return nil
}
}
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor { func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
@@ -819,11 +782,6 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
logger.Debugf("Exec command '%s'", cmd) logger.Debugf("Exec command '%s'", cmd)
isTerminal := cr.input.AllocatePTY isTerminal := cr.input.AllocatePTY
envList := make([]string, 0)
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
var wd string var wd string
if workdir != "" { if workdir != "" {
if strings.HasPrefix(workdir, "/") { if strings.HasPrefix(workdir, "/") {
@@ -840,7 +798,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
User: user, User: user,
Cmd: cmd, Cmd: cmd,
WorkingDir: wd, WorkingDir: wd,
Env: envList, Env: getEnvListFromMap(env),
TTY: isTerminal, TTY: isTerminal,
AttachStderr: true, AttachStderr: true,
AttachStdout: true, AttachStdout: true,
@@ -857,7 +815,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
} }
defer resp.Close() defer resp.Close()
err = cr.waitForCommand(ctx, resp.HijackedResponse, idResp, user, workdir) err = cr.waitForCommand(ctx, resp.HijackedResponse)
if err != nil { if err != nil {
return err return err
} }
@@ -907,15 +865,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
} }
} }
func (cr *containerReference) tryReadUID() common.Executor { func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse) error {
return cr.tryReadID("-u", func(id int) { cr.UID = id })
}
func (cr *containerReference) tryReadGID() common.Executor {
return cr.tryReadID("-g", func(id int) { cr.GID = id })
}
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
// Buffered so the copy goroutine never blocks on send if the grace-period // Buffered so the copy goroutine never blocks on send if the grace-period
@@ -957,6 +907,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
} }
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor { func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
dstPath = path.Clean(dstPath)
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath) return cr.missingContainerError("copy directory to %s", dstPath)
@@ -978,7 +929,11 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
logger.Error(err) logger.Error(err)
} }
}(tarFile) }(tarFile)
if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil { extractionRoot := "/"
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
}
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
return err return err
} }
@@ -987,14 +942,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
if err != nil { if err != nil {
return fmt.Errorf("failed to seek tar archive: %w", err) return fmt.Errorf("failed to seek tar archive: %w", err)
} }
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{ return cr.upload(ctx, extractionRoot, tarFile)
DestinationPath: "/",
Content: tarFile,
})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
return nil
} }
} }
@@ -1009,17 +957,17 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
} }
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath) common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{ return cr.upload(ctx, dstPath, &buf)
DestinationPath: dstPath,
Content: &buf,
})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
return nil
} }
} }
func (cr *containerReference) upload(ctx context.Context, dstPath string, content io.Reader) error {
if _, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{DestinationPath: dstPath, Content: content}); err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
return nil
}
func (cr *containerReference) attach() common.Executor { func (cr *containerReference) attach() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{ out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
+80 -46
View File
@@ -5,6 +5,7 @@
package container package container
import ( import (
"archive/tar"
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
@@ -31,44 +32,28 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDocker(t *testing.T) { func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
requireDocker(t) client := &mockDockerClient{}
ctx := context.Background() client.On("ContainerInspect", mock.Anything, "amd64-job", mobyclient.ContainerInspectOptions{}).
client, err := GetDockerClient(ctx) Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Env: []string{
require.NoError(t, err) "PATH=/opt/node/x64/bin:/usr/bin:/bin", "CONFLICT_VAR=container-value", "SOME_RANDOM_VAR=",
defer client.Close() "ANOTHER_ONE=old-value", "ANOTHER_ONE=BUT_I_HAVE_VALUE", "LITERAL='quoted'=$HOME\nsecond line",
}}}}, nil)
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
ContextDir: "testdata",
ImageTag: "envmergetest",
})
err = dockerBuild(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cr := &containerReference{
cli: client,
input: &NewContainerInput{
Image: "envmergetest",
},
}
env := map[string]string{ env := map[string]string{
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin", "PATH": "/custom/bin",
"RANDOM_VAR": "WITH_VALUE", "RANDOM_VAR": "WITH_VALUE",
"ANOTHER_VAR": "", "ANOTHER_VAR": "",
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES", "CONFLICT_VAR": "step-value",
} }
require.NoError(t, (&containerReference{cli: client, id: "amd64-job"}).UpdateFromImageEnv(&env)(t.Context()))
envExecutor := cr.extractFromImageEnv(&env)
err = envExecutor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, map[string]string{ assert.Equal(t, map[string]string{
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either", "PATH": "/custom/bin:/opt/node/x64/bin:/usr/bin:/bin",
"RANDOM_VAR": "WITH_VALUE", "RANDOM_VAR": "WITH_VALUE",
"ANOTHER_VAR": "", "ANOTHER_VAR": "",
"SOME_RANDOM_VAR": "", "SOME_RANDOM_VAR": "",
"ANOTHER_ONE": "BUT_I_HAVE_VALUE", "ANOTHER_ONE": "BUT_I_HAVE_VALUE",
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES", "CONFLICT_VAR": "step-value",
"LITERAL": "'quoted'=$HOME\nsecond line",
}, env) }, env)
} }
@@ -498,25 +483,45 @@ func TestRejectsMissingContainer(t *testing.T) {
check("Inspect after removal", err) check("Inspect after removal", err)
} }
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
// and the Copy completes against the fresh id.
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) { func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
ctx := context.Background() for _, testcase := range []struct {
client := &mockDockerClient{} name, destination, extractionRoot, entry string
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}). directory bool
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone")) }{
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}). {name: "files", destination: "/var/run/act", extractionRoot: "/var/run/act", entry: "x"},
Return(mobyclient.ContainerListResult{Items: []container.Summary{ {name: "action below act volume", directory: true, destination: "/var/run/act/actions/checkout/", extractionRoot: "/var/run/act", entry: "actions/checkout/x"},
{ID: "fresh", Names: []string{"/job-1"}}, {name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
}}, nil) } {
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool { t.Run(testcase.name, func(t *testing.T) {
return opts.DestinationPath == "/var/run/act" ctx := t.Context()
})).Return(mobyclient.CopyToContainerResult{}, nil) client := &mockDockerClient{}
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
client.On("CopyToContainer", ctx, "fresh", mock.Anything).
Run(func(args mock.Arguments) {
opts := args.Get(2).(mobyclient.CopyToContainerOptions)
assert.Equal(t, testcase.extractionRoot, opts.DestinationPath)
header, err := tar.NewReader(opts.Content).Next()
require.NoError(t, err)
assert.Equal(t, testcase.entry, header.Name)
}).Return(mobyclient.CopyToContainerResult{}, nil).Once()
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}} cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx)) if testcase.directory {
assert.Equal(t, "fresh", cr.id) source := t.TempDir()
client.AssertExpectations(t) require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
} else {
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
}
assert.Equal(t, "fresh", cr.id)
client.AssertExpectations(t)
})
}
} }
// Type assert containerReference implements ExecutionsEnvironment // Type assert containerReference implements ExecutionsEnvironment
@@ -923,6 +928,35 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
assert.Equal(t, 1, warnings("", "--privileged")) assert.Equal(t, 1, warnings("", "--privileged"))
} }
func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
for _, entrypoint := range [][]string{nil, {"sleep", "3600"}} {
for _, optionEntrypoint := range []string{"/sbin/dumb-init", ""} {
logger, hook := test.NewNullLogger()
cr := &containerReference{
input: &NewContainerInput{
NetworkMode: "bridge",
Entrypoint: entrypoint,
RunnerOptions: "--entrypoint=runner-entrypoint",
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
},
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
if len(entrypoint) > 0 {
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
} else {
assert.Equal(t, []string{optionEntrypoint}, opts.Config.Entrypoint)
}
require.NotNil(t, opts.HostConfig.Init)
assert.True(t, *opts.HostConfig.Init)
return mobyclient.ContainerCreateResult{ID: "created"}, nil
}},
}
require.NoError(t, cr.create(nil, nil)(common.WithLogger(t.Context(), logger)))
assert.Equal(t, "created", cr.id)
assert.Equal(t, len(entrypoint) > 0, len(hook.AllEntries()) == 1)
}
}
}
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) { func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
logger, _ := test.NewNullLogger() logger, _ := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{ cr := &containerReference{input: &NewContainerInput{
-5
View File
@@ -1,5 +0,0 @@
FROM scratch
ENV PATH="/this/path/does/not/exists/anywhere:/this/either"
ENV SOME_RANDOM_VAR=""
ENV ANOTHER_ONE="BUT_I_HAVE_VALUE"
ENV CONFLICT_VAR="I_EXIST_ONLY_HERE"
+3 -3
View File
@@ -130,11 +130,11 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
// SetRepositoryAndOwner resolves the repository of the context from the git // SetRepositoryAndOwner resolves the repository of the context from the git
// remote in repoPath when it is not set yet, and derives its owner. // remote in repoPath when it is not set yet, and derives its owner.
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, repoPath string) { func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, repoPath string) {
if ghc.Repository == "" { if ghc.Repository == "" {
repo, err := findGithubRepo(ctx, repoPath, githubInstance) repo, err := findGithubRepo(ctx, repoPath)
if err != nil { if err != nil {
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v, repoPath: %v): %v", githubInstance, repoPath, err) common.Logger(ctx).Warningf("unable to get git repo (repoPath: %v): %v", repoPath, err)
return return
} }
ghc.Repository = repo ghc.Repository = repo
+10 -2
View File
@@ -145,7 +145,11 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
containerActionDirCopy += `/` containerActionDirCopy += `/`
} }
defer git.AcquireCloneLock(actionDir)() unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
if !rc.Config.NoActionPatch { if !rc.Config.NoActionPatch {
// A concurrent job's prepare resets this directory, so patch under the copy's lock. // A concurrent job's prepare resets this directory, so patch under the copy's lock.
@@ -366,7 +370,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// Held across the whole build: the daemon drains contextDir lazily. // Held across the whole build: the daemon drains contextDir lazily.
inner := prepImage inner := prepImage
prepImage = func(ctx context.Context) error { prepImage = func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)() unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
return inner(ctx) return inner(ctx)
} }
} }
+8 -2
View File
@@ -143,6 +143,7 @@ runs:
} }
func TestActionRunner(t *testing.T) { func TestActionRunner(t *testing.T) {
t.Chdir(t.TempDir())
table := []struct { table := []struct {
name string name string
step actionStep step actionStep
@@ -366,7 +367,10 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
peerAcquired := make(chan struct{}) peerAcquired := make(chan struct{})
go func() { go func() {
unlock := git.AcquireCloneLock(actionDir) unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
if !assert.NoError(t, err) {
return
}
close(peerAcquired) close(peerAcquired)
unlock() unlock()
}() }()
@@ -400,7 +404,9 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
actionDir := t.TempDir() actionDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir)) unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
require.NoError(t, err)
unlockOnce := sync.OnceFunc(unlock)
defer unlockOnce() defer unlockOnce()
innerEntered := make(chan struct{}) innerEntered := make(chan struct{})
+5 -2
View File
@@ -29,7 +29,10 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
) )
const maxJobSummaryBytes = 1024 * 1024 const (
jobCleanupTimeout = time.Minute
maxJobSummaryBytes = 1024 * 1024
)
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit // jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
// so the rendered output makes the truncation visible instead of silently cutting off. // so the rendered output makes the truncation visible instead of silently cutting off.
@@ -249,7 +252,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
jobError := common.JobError(ctx) jobError := common.JobError(ctx)
var err error var err error
// always allow 1 min for stopping and removing the runner, even if we were cancelled // always allow 1 min for stopping and removing the runner, even if we were cancelled
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute) ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), jobCleanupTimeout)
defer cancel() defer cancel()
logger := common.Logger(ctx) logger := common.Logger(ctx)
+13 -16
View File
@@ -10,7 +10,6 @@ import (
"net/url" "net/url"
"path" "path"
"regexp" "regexp"
"strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/common/git"
@@ -27,7 +26,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
if !rc.Config.NoSkipCheckout { if !rc.Config.NoSkipCheckout {
// resolve the local workflow against the workspace root, not the process // resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked // working directory, so it is found regardless of where the runner is invoked
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path) return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
} }
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha) uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
@@ -41,7 +40,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir), cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
) )
} }
@@ -59,7 +58,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir), cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
) )
} }
@@ -87,11 +86,16 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory
} }
} }
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor { func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cached bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
// Serialize workflow reads with cache updates.
planner, err := func() (model.WorkflowPlanner, error) { planner, err := func() (model.WorkflowPlanner, error) {
defer git.AcquireCloneLock(directory)() if cached {
unlock, err := git.AcquireCloneLock(ctx, directory)
if err != nil {
return nil, err
}
defer unlock()
}
return model.NewWorkflowPlanner(path.Join(directory, workflow), true) return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
}() }()
if err != nil { if err != nil {
@@ -135,11 +139,7 @@ type remoteReusableWorkflow struct {
} }
func (r *remoteReusableWorkflow) CloneURL() string { func (r *remoteReusableWorkflow) CloneURL() string {
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case. return fmt.Sprintf("%s/%s/%s", withHTTPS(r.URL), r.Owner, r.Repo)
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
}
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
} }
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`) var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
@@ -233,10 +233,7 @@ func onInstanceHost(host string, instanceURLs ...string) bool {
if candidate == "" { if candidate == "" {
continue continue
} }
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") { if u, err := url.Parse(withHTTPS(candidate)); err == nil && u.Host == host {
candidate = "https://" + candidate
}
if u, err := url.Parse(candidate); err == nil && u.Host == host {
return true return true
} }
} }
+7 -6
View File
@@ -8,7 +8,6 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"io/fs"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -84,14 +83,16 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
workflowDir := t.TempDir() workflowDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir)) unlock, err := git.AcquireCloneLock(t.Context(), workflowDir)
require.NoError(t, err)
unlockOnce := sync.OnceFunc(unlock)
defer unlockOnce() defer unlockOnce()
rc := &RunContext{ rc := &RunContext{
Config: &Config{}, Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}}, Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
} }
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml") exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml", true)
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- exec(context.Background()) }() go func() { done <- exec(context.Background()) }()
@@ -113,8 +114,9 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
} }
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) { func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
workdir := t.TempDir() workdir := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
require.NoError(t, os.Mkdir(workdir+".lock", 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} { for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
@@ -123,8 +125,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
} }
err := newLocalReusableWorkflowExecutor(rc)(t.Context()) err := newLocalReusableWorkflowExecutor(rc)(t.Context())
require.Error(t, err) require.ErrorContains(t, err, "workflow is not valid")
require.NotErrorIs(t, err, fs.ErrNotExist)
} }
} }
+24 -40
View File
@@ -1541,14 +1541,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
*ghc = merged *ghc = merged
instance := rc.Config.GitHubInstance rc.setInstanceURLs(ghc)
if !strings.HasPrefix(instance, "http://") && ghc.GraphQLURL = "" // Gitea doesn't support graphql
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
}
ghc.ServerURL = instance
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
ghc.GraphQLURL = "" // Gitea doesn't support graphql
return ghc return ghc
} }
} }
@@ -1562,7 +1556,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.SetBaseAndHeadRef() ghc.SetBaseAndHeadRef()
repoPath := rc.Config.Workdir repoPath := rc.Config.Workdir
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, repoPath) ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
if ghc.Ref == "" { if ghc.Ref == "" {
ghcontext.SetRef(ctx, ghc, repoPath) ghcontext.SetRef(ctx, ghc, repoPath)
} }
@@ -1572,27 +1566,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.SetRefTypeAndName() ghc.SetRefTypeAndName()
// defaults rc.setInstanceURLs(ghc)
ghc.ServerURL = "https://github.com"
ghc.APIURL = "https://api.github.com"
ghc.GraphQLURL = "https://api.github.com/graphql"
// per GHES
if rc.Config.GitHubInstance != "github.com" {
ghc.ServerURL = "https://" + rc.Config.GitHubInstance
ghc.APIURL = fmt.Sprintf("https://%s/api/v3", rc.Config.GitHubInstance)
ghc.GraphQLURL = fmt.Sprintf("https://%s/api/graphql", rc.Config.GitHubInstance)
}
{ // Adapt to Gitea
instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") &&
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
}
ghc.ServerURL = instance
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
ghc.GraphQLURL = "" // Gitea doesn't support graphql
}
// allow to be overridden by user // allow to be overridden by user
if rc.Config.Env["GITHUB_SERVER_URL"] != "" { if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
@@ -1608,6 +1582,20 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
return ghc return ghc
} }
func (rc *RunContext) setInstanceURLs(ghc *model.GithubContext) {
if instance := strings.TrimRight(rc.Config.GitHubInstance, "/"); instance != "" {
ghc.ServerURL = withHTTPS(instance)
ghc.APIURL = ghc.ServerURL + "/api/v1"
}
}
func withHTTPS(u string) string {
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
return u
}
return "https://" + u
}
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool { func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() == model.StepTypeInvalid { if step.Type() == model.StepTypeInvalid {
// This will be errored out by the executor later, we need this here to avoid a null panic though // This will be errored out by the executor later, we need this here to avoid a null panic though
@@ -1657,20 +1645,16 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_HEAD_REF"] = github.HeadRef env["GITHUB_HEAD_REF"] = github.HeadRef
env["GITHUB_SERVER_URL"] = github.ServerURL env["GITHUB_SERVER_URL"] = github.ServerURL
env["GITHUB_API_URL"] = github.APIURL env["GITHUB_API_URL"] = github.APIURL
env["GITHUB_GRAPHQL_URL"] = github.GraphQLURL
{ // Adapt to Gitea for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
instance := rc.Config.GitHubInstance if env[name] == "" {
if !strings.HasPrefix(instance, "http://") && delete(env, name)
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
} }
env["GITHUB_SERVER_URL"] = instance
env["GITHUB_API_URL"] = instance + "/api/v1" // the version of Gitea is v1
env["GITHUB_GRAPHQL_URL"] = "" // Gitea doesn't support graphql
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
} }
env["GITHUB_GRAPHQL_URL"] = ""
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
env["RUNNER_NAME"] = rc.Config.RunnerName env["RUNNER_NAME"] = rc.Config.RunnerName
env["RUNNER_ENVIRONMENT"] = "self-hosted" env["RUNNER_ENVIRONMENT"] = "self-hosted"
if workspace := parentDir(github.Workspace); workspace != "" { if workspace := parentDir(github.Workspace); workspace != "" {
+31
View File
@@ -915,6 +915,7 @@ func TestGetGitHubContext(t *testing.T) {
Run: &model.Run{ Run: &model.Run{
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Name: "GitHubContextTest", Name: "GitHubContextTest",
Jobs: map[string]*model.Job{"job1": {}},
}, },
}, },
Name: "GitHubContextTest", Name: "GitHubContextTest",
@@ -960,6 +961,36 @@ func TestGetGitHubContext(t *testing.T) {
assert.Equal(t, "preset-actor", ghc.Actor) assert.Equal(t, "preset-actor", ghc.Actor)
assert.Equal(t, "triggerer", ghc.TriggeringActor) assert.Equal(t, "triggerer", ghc.TriggeringActor)
assert.Equal(t, "job1", ghc.Job) assert.Equal(t, "job1", ghc.Job)
preset := &model.GithubContext{ServerURL: "https://preset.test", APIURL: "https://preset.test/api/v1"}
for _, testcase := range []struct {
name, instance, serverURL, apiURL string
preset *model.GithubContext
env map[string]string
}{
{name: "unknown instance"},
{name: "bare host with trailing slash", instance: "gitea.test/", serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
{name: "scheme port and base path", instance: "http://gitea.test:3000/gitea/", serverURL: "http://gitea.test:3000/gitea", apiURL: "http://gitea.test:3000/gitea/api/v1"},
{name: "daemon instance replaces preset endpoints", instance: "gitea.test", preset: preset, serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
{name: "daemon keeps preset endpoints without instance", preset: preset, serverURL: "https://preset.test", apiURL: "https://preset.test/api/v1"},
{name: "environment overrides", env: map[string]string{"GITHUB_SERVER_URL": "https://override.test", "GITHUB_API_URL": "https://override.test/api"}, serverURL: "https://override.test", apiURL: "https://override.test/api"},
} {
t.Run(testcase.name, func(t *testing.T) {
rc.Config.GitHubInstance = testcase.instance
rc.Config.PresetGitHubContext = testcase.preset
rc.Config.Env = map[string]string{"GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "SHA_REF": "1234567890"}
maps.Copy(rc.Config.Env, testcase.env)
github := rc.getGithubContext(t.Context())
assert.Equal(t, testcase.serverURL, github.ServerURL)
assert.Equal(t, testcase.apiURL, github.APIURL)
env := map[string]string{}
rc.withGithubEnv(t.Context(), github, env)
_, exported := env["GITHUB_SERVER_URL"]
assert.Equal(t, testcase.serverURL != "", exported)
assert.Equal(t, testcase.serverURL, env["GITHUB_SERVER_URL"])
assert.Equal(t, testcase.apiURL, env["GITHUB_API_URL"])
})
}
} }
func TestGetGithubContextRef(t *testing.T) { func TestGetGithubContextRef(t *testing.T) {
+3 -8
View File
@@ -5,6 +5,7 @@
package runner package runner
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
@@ -49,7 +50,7 @@ type Config struct {
ContainerDaemonSocket string // Path to Docker daemon socket ContainerDaemonSocket string // Path to Docker daemon socket
ContainerOptions string // Options for the job container ContainerOptions string // Options for the job container
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
GitHubInstance string // GitHub instance to use, default "github.com" GitHubInstance string // instance hostname or URL, empty when unknown
ContainerCapAdd []string // list of kernel capabilities to add to the containers ContainerCapAdd []string // list of kernel capabilities to add to the containers
ContainerCapDrop []string // list of kernel capabilities to remove from the containers ContainerCapDrop []string // list of kernel capabilities to remove from the containers
ArtifactServerPath string // the path where the artifact server stores uploads ArtifactServerPath string // the path where the artifact server stores uploads
@@ -111,13 +112,7 @@ func (c Config) GetToken() string {
// DefaultActionURL returns the host used for implicit remote actions. // DefaultActionURL returns the host used for implicit remote actions.
func (c Config) DefaultActionURL() string { func (c Config) DefaultActionURL() string {
if c.DefaultActionInstance != "" { return cmp.Or(c.DefaultActionInstance, c.GitHubInstance, "github.com")
return c.DefaultActionInstance
}
if c.GitHubInstance != "" {
return c.GitHubInstance
}
return "github.com"
} }
type caller struct { type caller struct {
+11 -5
View File
@@ -186,19 +186,25 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if err == nil { if err == nil {
err = insecureErr err = insecureErr
} }
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil { fileCommandCtx := ctx
if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force
var cancelFileCommands context.CancelFunc
fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancelFileCommands()
}
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
err = fileErr err = fileErr
} }
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil { if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
err = fileErr err = fileErr
} }
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil { if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
err = fileErr err = fileErr
} }
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil { if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
err = fileErr err = fileErr
} }
_ = rc.JobContainer.Copy(actPath, files...)(ctx) _ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
if err == nil { if err == nil {
logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString) logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
+11 -3
View File
@@ -88,6 +88,12 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
} }
} }
unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
// Best effort: the download report falls back to the ref alone when the commit is unknown. // Best effort: the download report falls back to the ref alone when the commit is unknown.
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil { if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err) common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
@@ -100,7 +106,6 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return f, f, err return f, f, err
} }
defer git.AcquireCloneLock(actionDir)()
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile) actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
sar.action = actionModel sar.action = actionModel
return err return err
@@ -251,8 +256,8 @@ func (ra *remoteAction) CloneURL(u string) string {
if ra.URL == "" { if ra.URL == "" {
// keep an absolute local path as-is (used by tests to resolve actions from a local // keep an absolute local path as-is (used by tests to resolve actions from a local
// repo); only bare host names get the https:// scheme prepended // repo); only bare host names get the https:// scheme prepended
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) { if !filepath.IsAbs(u) {
u = "https://" + u u = withHTTPS(u)
} }
} else { } else {
u = ra.URL u = ra.URL
@@ -301,6 +306,9 @@ func newRemoteAction(action string, github *model.GithubContext, instanceURL str
} }
ra.Org, ra.Repo, _ = strings.Cut(repo, "/") ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
ra.URL, ra.Ref = github.ServerURL, ref ra.URL, ra.Ref = github.ServerURL, ref
if ra.URL == "" {
return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
}
if ra.Org == "" || ra.Repo == "" || ra.Ref == "" { if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action) return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
} }
+4 -3
View File
@@ -193,7 +193,7 @@ func TestStepActionRemote(t *testing.T) {
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{ Config: &Config{
GitHubInstance: "github.com", GitHubInstance: "github.com",
ActionCacheDir: "/tmp/test-cache", ActionCacheDir: t.TempDir(),
}, },
Run: &model.Run{ Run: &model.Run{
JobID: "1", JobID: "1",
@@ -447,7 +447,7 @@ func TestStepActionRemotePost(t *testing.T) {
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{ Config: &Config{
GitHubInstance: "https://github.com", GitHubInstance: "https://github.com",
ActionCacheDir: "/tmp/test-cache", ActionCacheDir: t.TempDir(),
}, },
JobContainer: cm, JobContainer: cm,
Run: &model.Run{ Run: &model.Run{
@@ -688,6 +688,7 @@ func Test_newRemoteActionSelfRepo(t *testing.T) {
Ref: "v1", Ref: "v1",
}, },
}, },
{name: "unknown instance", action: "$/.gitea/actions/build", github: &model.GithubContext{Repository: "owner/workflow-repo", Sha: "abc123"}},
{name: "empty path", action: "$/", github: workflow}, {name: "empty path", action: "$/", github: workflow},
{name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow}, {name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow},
{name: "path traversal", action: "$/../escape", github: workflow}, {name: "path traversal", action: "$/../escape", github: workflow},
@@ -867,7 +868,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
Config: &Config{ Config: &Config{
GitHubInstance: tt.gitHubInstance, GitHubInstance: tt.gitHubInstance,
DefaultActionInstance: tt.defaultActionInstance, DefaultActionInstance: tt.defaultActionInstance,
ActionCacheDir: "/tmp/test-cache", ActionCacheDir: t.TempDir(),
// Mirrors the state of a composite RunContext: job secrets are // Mirrors the state of a composite RunContext: job secrets are
// stripped, but the job token is still reachable via Config.Token. // stripped, but the job token is still reachable via Config.Token.
Secrets: nil, Secrets: nil,
+46 -2
View File
@@ -7,9 +7,11 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"io"
"os" "os"
"strings" "strings"
"testing" "testing"
"testing/synctest"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
@@ -189,7 +191,6 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_ACTION_PATH": "", "GITHUB_ACTION_PATH": "",
"GITHUB_ACTION_REF": "", "GITHUB_ACTION_REF": "",
"GITHUB_ACTION_REPOSITORY": "", "GITHUB_ACTION_REPOSITORY": "",
"GITHUB_API_URL": "https:///api/v1", // Gitea uses api/v1 (upstream GitHub: api/v3)
"GITHUB_BASE_REF": "", "GITHUB_BASE_REF": "",
"GITHUB_EVENT_NAME": "", "GITHUB_EVENT_NAME": "",
"GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json", "GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json",
@@ -200,7 +201,6 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_RUN_ATTEMPT": "", "GITHUB_RUN_ATTEMPT": "",
"GITHUB_RUN_ID": "runId", "GITHUB_RUN_ID": "runId",
"GITHUB_RUN_NUMBER": "1", "GITHUB_RUN_NUMBER": "1",
"GITHUB_SERVER_URL": "https://",
"GITHUB_WORKFLOW": "", "GITHUB_WORKFLOW": "",
"INPUT_ID": "1234567", "INPUT_ID": "1234567",
"INPUT_STEP_WITH": "with-value", "INPUT_STEP_WITH": "with-value",
@@ -475,6 +475,26 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
require.NoError(t, errB) require.NoError(t, errB)
} }
type fileCommandTestContainer struct {
*container.HostEnvironment
}
func (c *fileCommandTestContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return func(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
return c.HostEnvironment.UpdateFromEnv(srcPath, env)(ctx)
}
}
func (c *fileCommandTestContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return c.HostEnvironment.GetContainerArchive(ctx, srcPath)
}
func TestRunStepExecutorParity(t *testing.T) { func TestRunStepExecutorParity(t *testing.T) {
newStep := func(t *testing.T, stepModel *model.Step) *stepRun { newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
rc := createRunContext(t) rc := createRunContext(t)
@@ -527,6 +547,30 @@ func TestRunStepExecutorParity(t *testing.T) {
} }
}) })
t.Run("file commands survive a job timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
step := newStep(t, &model.Step{ID: "interrupted"})
ctx, cancel := applyJobTimeout(t.Context(), step.RunContext, &model.Job{TimeoutMinutes: "1"})
defer cancel()
step.RunContext.JobContainer = &fileCommandTestContainer{step.RunContext.JobContainer.(*container.HostEnvironment)}
err := runStepExecutor(step, stepStageMain, func(execCtx context.Context) error {
for name, content := range map[string]string{
"GITHUB_ENV": "KEPT_ENV=value\n", "GITHUB_STATE": "saved=value\n",
"GITHUB_OUTPUT": "kept=value\n", "GITHUB_PATH": "/kept/bin\n",
} {
require.NoError(t, os.WriteFile(step.env[name], []byte(content), 0o600))
}
<-execCtx.Done()
return execCtx.Err()
})(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, "value", step.RunContext.Env["KEPT_ENV"])
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
assert.Equal(t, "value", step.RunContext.StepResults[step.Step.ID].Outputs["kept"])
assert.Contains(t, step.RunContext.ExtraPath, "/kept/bin")
})
})
t.Run("composite child files do not leak", func(t *testing.T) { t.Run("composite child files do not leak", func(t *testing.T) {
outer := newStep(t, &model.Step{ID: "outer"}) outer := newStep(t, &model.Step{ID: "outer"})
childRC := &RunContext{ childRC := &RunContext{
+36 -1
View File
@@ -5,8 +5,10 @@ package cmd
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -29,6 +31,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
runnerv1 "gitea.dev/actionslib/runner/v1"
"github.com/avast/retry-go/v5"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -185,8 +189,25 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}() }()
// declare the labels of the runner before fetching tasks // declare the labels of the runner before fetching tasks
resp, err := runner.Declare(ctx, ls.Names()) resp, err := retry.NewWithData[*connect.Response[runnerv1.DeclareResponse]](
retry.Context(ctx),
retry.UntilSucceeded(),
retry.Delay(declareRetryDelay),
retry.DelayType(retry.FixedDelay),
retry.RetryIf(shouldRetryDeclare),
retry.OnRetry(func(attempt uint, err error) {
if attempt == 0 {
log.WithError(err).Warn("fail to invoke Declare, retrying until Gitea is reachable")
} else {
log.WithError(err).Debug("fail to invoke Declare, retrying")
}
}),
).Do(func() (*connect.Response[runnerv1.DeclareResponse], error) {
return runner.Declare(ctx, ls.Names())
})
switch { switch {
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
return nil
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented: case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
log.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later") log.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later")
return err return err
@@ -352,6 +373,20 @@ func initLogging(cfg *config.Config) {
} }
} }
var declareRetryDelay = 30 * time.Second
func shouldRetryDeclare(err error) bool {
if errors.As(err, new(*tls.CertificateVerificationError)) || errors.As(err, new(tls.AlertError)) || errors.Is(err, http.ErrSchemeMismatch) { // misconfigurations never heal
return false
}
switch connect.CodeOf(err) {
case connect.CodeUnauthenticated, connect.CodeUnimplemented, connect.CodePermissionDenied, connect.CodeInvalidArgument:
return false
default:
return !client.IsRegistrationRejected(err)
}
}
var commonSocketPaths = []string{ var commonSocketPaths = []string{
"/var/run/docker.sock", "/var/run/docker.sock",
"/run/podman/podman.sock", "/run/podman/podman.sock",
+34 -3
View File
@@ -5,11 +5,13 @@ package cmd
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -87,12 +89,19 @@ var errUnregisteredRunner = errors.New("rpc error: code = Unauthenticated desc =
type daemonTestService struct { type daemonTestService struct {
runnerv1connect.UnimplementedRunnerServiceHandler runnerv1connect.UnimplementedRunnerServiceHandler
declareErr, fetchErr error declareErr, fetchErr error
declareCalls atomic.Int32
beforeTask bool beforeTask bool
cancel context.CancelFunc cancel context.CancelFunc
} }
func (s *daemonTestService) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) { func (s *daemonTestService) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
if s.declareCalls.Add(1) == 1 {
return nil, connect.NewError(connect.CodeUnavailable, errors.New("bad gateway"))
}
if s.declareErr != nil { if s.declareErr != nil {
if s.beforeTask {
s.cancel()
}
return nil, s.declareErr return nil, s.declareErr
} }
return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{Name: "test"}}), nil return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{Name: "test"}}), nil
@@ -117,7 +126,9 @@ func (*daemonTestService) UpdateLog(_ context.Context, req *connect.Request[runn
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
} }
func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) { func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay)
declareRetryDelay = 0
for _, tc := range []struct { for _, tc := range []struct {
name string name string
ephemeral, beforeTask bool ephemeral, beforeTask bool
@@ -132,11 +143,13 @@ func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
{"ephemeral proxy 401 on fetch", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("HTTP status 401 Unauthorized")), false}, {"ephemeral proxy 401 on fetch", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("HTTP status 401 Unauthorized")), false},
{"ephemeral rejected on declare", true, false, errUnregisteredRunner, nil, true}, {"ephemeral rejected on declare", true, false, errUnregisteredRunner, nil, true},
{"persistent rejected on declare", false, false, errUnregisteredRunner, nil, false}, {"persistent rejected on declare", false, false, errUnregisteredRunner, nil, false},
{"ephemeral stopped during declare outage", true, true, connect.NewError(connect.CodeUnavailable, errors.New("bad gateway")), nil, false},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel() defer cancel()
_, handler := runnerv1connect.NewRunnerServiceHandler(&daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel}) service := &daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel}
_, handler := runnerv1connect.NewRunnerServiceHandler(service)
server := httptest.NewServer(http.StripPrefix("/api/actions", handler)) server := httptest.NewServer(http.StripPrefix("/api/actions", handler))
defer server.Close() defer server.Close()
dir := t.TempDir() dir := t.TempDir()
@@ -145,11 +158,12 @@ func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600)) require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral})) require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil) err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
if tc.declareErr == nil && tc.fetchErr == nil { if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask {
require.NoError(t, err) require.NoError(t, err)
} else { } else {
require.Error(t, err) require.Error(t, err)
} }
require.Equal(t, int32(2), service.declareCalls.Load())
if tc.removed { if tc.removed {
require.NoFileExists(t, regFile) require.NoFileExists(t, regFile)
} else { } else {
@@ -158,3 +172,20 @@ func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
}) })
} }
} }
func TestShouldRetryDeclare(t *testing.T) {
for _, tc := range []struct {
err error
retry bool
}{
{connect.NewError(connect.CodeUnavailable, errors.New("bad gateway")), true},
{connect.NewWireError(connect.CodeInternal, errors.New("update runner")), true},
{connect.NewError(connect.CodeUnavailable, &tls.CertificateVerificationError{Err: errors.New("unknown authority")}), false},
{connect.NewError(connect.CodeUnavailable, http.ErrSchemeMismatch), false},
{connect.NewError(connect.CodeUnavailable, tls.AlertError(42)), false},
{connect.NewError(connect.CodePermissionDenied, errors.New("forbidden")), false},
{connect.NewWireError(connect.CodeUnknown, errors.New("unregistered runner")), false},
} {
require.Equal(t, tc.retry, shouldRetryDeclare(tc.err), tc.err.Error())
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
# #
# Usage: scripts/test-dind.sh [target] [-- go-test-args...] # Usage: scripts/test-dind.sh [target] [-- go-test-args...]
# target: dind (default), dind-rootless, or podman to run PODMAN_TEST_IMAGE's API service instead # target: dind (default), dind-rootless, or podman to run PODMAN_TEST_IMAGE's API service instead
# go-test-args: passed verbatim to `go test`. Defaults cover image env extraction, # go-test-args: passed verbatim to `go test`. Defaults cover image platform detection,
# symlink copying, a mounted Docker job using cached images and the # symlink copying, a mounted Docker job using cached images and the
# image's s6 supervision, or the Docker proxy probe for podman. # image's s6 supervision, or the Docker proxy probe for podman.
# #
@@ -55,7 +55,7 @@ else
daemon_args=(-e DOCKER_TLS_CERTDIR= --entrypoint dockerd-entrypoint.sh "$image" --host=tcp://0.0.0.0:2375) daemon_args=(-e DOCKER_TLS_CERTDIR= --entrypoint dockerd-entrypoint.sh "$image" --host=tcp://0.0.0.0:2375)
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
default_tests=true default_tests=true
set -- -count=1 -race -run '^TestDocker$' ./act/container/ set -- -count=1 -race -run '^TestImageExistsLocally$' ./act/container/
fi fi
fi fi