mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-05 06:40:07 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3647395a | ||
|
|
c158ac5472 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 |
11
README.md
11
README.md
@@ -266,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
|
||||
|
||||
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||
|
||||
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||
|
||||
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||
```
|
||||
|
||||
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works.
|
||||
|
||||
#### Proxy
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
|
||||
@@ -391,7 +391,11 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||
_, _, present := refRevision(r, input.Ref)
|
||||
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||
|
||||
if refresh {
|
||||
err = r.FetchContext(ctx, &fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return err
|
||||
@@ -411,23 +415,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we need to know if it's a tag or a branch
|
||||
// And the easiest way to do it is duck typing
|
||||
//
|
||||
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
||||
// it was a sha
|
||||
refType := "tag"
|
||||
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
||||
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
||||
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||
refType = "sha"
|
||||
rev = plumbing.Revision(input.Ref)
|
||||
} else {
|
||||
refType = "branch"
|
||||
rev = plumbing.Revision(rName)
|
||||
}
|
||||
}
|
||||
rev, refType, _ := refRevision(r, input.Ref)
|
||||
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
@@ -459,7 +447,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
reusedMsg := ""
|
||||
|
||||
switch {
|
||||
case !isOfflineMode && !shallow:
|
||||
case refresh && !shallow:
|
||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||
@@ -501,27 +489,37 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||
if input.Depth > 0 {
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if plumbing.IsHash(input.Ref) {
|
||||
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
@@ -530,14 +528,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||
}
|
||||
|
||||
func removePartialClone(dir string) error {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("remove partial clone %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
||||
r, err := git.PlainInit(input.Dir, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.FetchContext(ctx, &git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
||||
Depth: input.Depth,
|
||||
Tags: git.NoTags,
|
||||
Auth: opts.Auth,
|
||||
Progress: opts.Progress,
|
||||
InsecureSkipTLS: opts.InsecureSkipTLS,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
||||
func pinnedRefSpec(sha string) config.RefSpec {
|
||||
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
||||
}
|
||||
|
||||
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
|
||||
if plumbing.IsHash(ref) {
|
||||
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||
return plumbing.Revision(ref), "sha", err == nil
|
||||
}
|
||||
if _, err := r.Tag(ref); err == nil {
|
||||
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
|
||||
}
|
||||
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||
return plumbing.Revision(remoteRef), "branch", true
|
||||
}
|
||||
rev := plumbing.Revision(ref)
|
||||
_, err := r.ResolveRevision(rev)
|
||||
return rev, "sha", err == nil
|
||||
}
|
||||
|
||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
if _, err := r.Reference(tagRef, false); err == nil {
|
||||
@@ -548,5 +597,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||
}
|
||||
if plumbing.IsHash(ref) {
|
||||
// The broad refspec carries only advertised tips, never a pinned commit.
|
||||
return pinnedRefSpec(ref), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,6 +25,9 @@ import (
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -435,7 +439,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||
}
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||
|
||||
@@ -461,14 +465,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
})
|
||||
|
||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
||||
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
||||
return remote
|
||||
}
|
||||
|
||||
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
assert.NoFileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||
remote := unadvertisedRemote(t, true)
|
||||
dir := t.TempDir()
|
||||
cloneAt := func(ref string) error {
|
||||
return NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remote, Ref: ref, Dir: dir, Depth: 1,
|
||||
})(t.Context())
|
||||
}
|
||||
|
||||
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||
assert.FileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||
|
||||
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||
require.NoError(t, cloneAt(olderSHA))
|
||||
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||
|
||||
require.NoError(t, os.RemoveAll(remote))
|
||||
require.NoError(t, cloneAt(olderSHA))
|
||||
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
@@ -491,6 +524,77 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Ref string
|
||||
Depth int
|
||||
}{
|
||||
"shallow branch": {"main", 1},
|
||||
"full clone tag": {"v1", 0},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
counter := installCountingTransport(t)
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
|
||||
})(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
|
||||
pinned := gitRevParse(t, workDir, "HEAD")
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
||||
|
||||
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||
})(t.Context()))
|
||||
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type countingTransport struct {
|
||||
transport.Transport
|
||||
sessions atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||
c.sessions.Add(1)
|
||||
return c.Transport.NewUploadPackSession(ep, auth)
|
||||
}
|
||||
|
||||
func installCountingTransport(t *testing.T) *countingTransport {
|
||||
t.Helper()
|
||||
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||
gogitclient.InstallProtocol("file", counter)
|
||||
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
|
||||
@@ -75,6 +75,17 @@ const (
|
||||
// fragment, missingContainerError composes it into the message every operation shares.
|
||||
var ErrContainerNotFound = errors.New("does not exist")
|
||||
|
||||
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
||||
type DockerProxy struct {
|
||||
Socket string
|
||||
close func(context.Context) error
|
||||
}
|
||||
|
||||
// Close removes what the job created through the socket, then stops serving it.
|
||||
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||
return p.close(ctx)
|
||||
}
|
||||
|
||||
// Info is a snapshot of a container, as of one inspect.
|
||||
type Info struct {
|
||||
ID string
|
||||
@@ -84,13 +95,14 @@ type Info struct {
|
||||
// HealthOutput is the last healthcheck probe's output.
|
||||
HealthOutput string
|
||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||
Mounts map[string]string // container path to its source on the daemon
|
||||
}
|
||||
|
||||
// Container for managing docker run containers
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
DumpLogs(ctx context.Context) error
|
||||
|
||||
276
act/container/docker_proxy.go
Normal file
276
act/container/docker_proxy.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
const (
|
||||
jobLabel = "com.gitea.runner.job"
|
||||
maxCreateBody = 8 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||
proxyProbe struct {
|
||||
sync.Mutex
|
||||
decided bool
|
||||
dir string
|
||||
}
|
||||
)
|
||||
|
||||
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files.
|
||||
func DockerProxyDir(ctx context.Context) string {
|
||||
proxyProbe.Lock()
|
||||
defer proxyProbe.Unlock()
|
||||
if proxyProbe.decided {
|
||||
return proxyProbe.dir
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "gitea-runner-docker")
|
||||
ok, err := daemonSeesDir(ctx, dir)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err)
|
||||
return ""
|
||||
}
|
||||
proxyProbe.decided = true
|
||||
if ok {
|
||||
proxyProbe.dir = dir
|
||||
} else {
|
||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
|
||||
}
|
||||
return proxyProbe.dir
|
||||
}
|
||||
|
||||
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return false, err
|
||||
}
|
||||
probe := filepath.Join(dir, "probe")
|
||||
if err := os.WriteFile(probe, nil, 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(images.Items) == 0 {
|
||||
return false, errors.New("no image to probe with yet")
|
||||
}
|
||||
// creating validates that a bind source exists on the daemon's side, nothing is started
|
||||
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
||||
HostConfig: &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}},
|
||||
})
|
||||
if cerrdefs.IsInvalidArgument(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true})
|
||||
return true, err
|
||||
}
|
||||
|
||||
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(job))
|
||||
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
|
||||
_ = os.Remove(socket)
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, err := os.Stat(daemonSocket); err == nil {
|
||||
_ = os.Chmod(socket, info.Mode().Perm())
|
||||
}
|
||||
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
||||
}
|
||||
transport := &http.Transport{DialContext: dial}
|
||||
forward := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.Out.URL.Scheme = "http"
|
||||
r.Out.URL.Host = "docker"
|
||||
},
|
||||
Transport: transport,
|
||||
}
|
||||
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method != http.MethodPost:
|
||||
case createPath.MatchString(r.URL.Path):
|
||||
if err := addLabel(r, job); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case rawStreamPath.MatchString(r.URL.Path):
|
||||
tunnel(w, r, dial)
|
||||
return
|
||||
}
|
||||
forward.ServeHTTP(w, r)
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
|
||||
err := removeJobResources(ctx, job)
|
||||
_ = server.Close()
|
||||
transport.CloseIdleConnections()
|
||||
_ = os.Remove(socket)
|
||||
return err
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func addLabel(r *http.Request, job string) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > maxCreateBody {
|
||||
return errors.New("create request too large")
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &fields); err != nil {
|
||||
return fmt.Errorf("invalid create request: %w", err)
|
||||
}
|
||||
key := "Labels"
|
||||
for name := range fields {
|
||||
if strings.EqualFold(name, key) {
|
||||
key = name
|
||||
break
|
||||
}
|
||||
}
|
||||
labels := map[string]string{}
|
||||
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" {
|
||||
if err := json.Unmarshal(raw, &labels); err != nil {
|
||||
return fmt.Errorf("invalid create request: %w", err)
|
||||
}
|
||||
}
|
||||
labels[jobLabel] = job
|
||||
if fields[key], err = json.Marshal(labels); err != nil {
|
||||
return err
|
||||
}
|
||||
if body, err = json.Marshal(fields); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
r.ContentLength = int64(len(body))
|
||||
r.TransferEncoding = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
||||
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
upstream, err := dial(r.Context(), "", "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
if err := r.Write(upstream); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
downstream, buffered, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer downstream.Close()
|
||||
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
_, _ = io.Copy(upstream, downstream)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(downstream, upstream)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
<-done
|
||||
}
|
||||
|
||||
func removeJobResources(ctx context.Context, job string) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
return removeLabelled(ctx, cli, job)
|
||||
}
|
||||
|
||||
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
||||
logger := common.Logger(ctx)
|
||||
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
||||
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, c := range containers.Items {
|
||||
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
||||
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
||||
}
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return errors.Join(append(errs, err)...)
|
||||
}
|
||||
for _, n := range networks.Items {
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
}
|
||||
}
|
||||
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return errors.Join(append(errs, err)...)
|
||||
}
|
||||
for _, v := range volumes.Items {
|
||||
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
270
act/container/docker_proxy_test.go
Normal file
270
act/container/docker_proxy_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"github.com/moby/moby/api/types/volume"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
|
||||
func shortTempDir(t testing.TB) string {
|
||||
t.Helper()
|
||||
t.Setenv("TMPDIR", "/tmp")
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
|
||||
t.Helper()
|
||||
host := cli.DaemonHost()
|
||||
if !strings.HasPrefix(host, "unix://") {
|
||||
t.Skipf("skipping: daemon at %s is not a unix socket", host)
|
||||
}
|
||||
return strings.TrimPrefix(host, "unix://")
|
||||
}
|
||||
|
||||
func TestDockerProxy(t *testing.T) {
|
||||
bodies := make(chan []byte, 8)
|
||||
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
|
||||
listener, err := net.Listen("unix", daemonSocket)
|
||||
require.NoError(t, err)
|
||||
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/create"):
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case strings.HasSuffix(r.URL.Path, "/start"):
|
||||
conn, buffered, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n"))
|
||||
_, _ = io.Copy(conn, buffered)
|
||||
default:
|
||||
w.Header().Set("Api-Version", "1.47")
|
||||
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
|
||||
}
|
||||
})}
|
||||
go func() { _ = daemon.Serve(listener) }()
|
||||
t.Cleanup(func() { _ = daemon.Close() })
|
||||
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
||||
}}}
|
||||
|
||||
t.Run("labels creates", func(t *testing.T) {
|
||||
for path, body := range map[string]string{
|
||||
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`,
|
||||
"/networks/create": `{"Name":"n"}`,
|
||||
"/volumes/create": `{"Name":"v","labels":null}`,
|
||||
} {
|
||||
resp, err := client.Post("http://docker"+path, "application/json", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(<-bodies, &got))
|
||||
key := "Labels"
|
||||
if _, ok := got["labels"]; ok {
|
||||
key = "labels"
|
||||
}
|
||||
var labels map[string]string
|
||||
require.NoError(t, json.Unmarshal(got[key], &labels))
|
||||
assert.Equal(t, "job-1", labels[jobLabel], path)
|
||||
if strings.Contains(body, "own") {
|
||||
assert.Equal(t, "1", labels["own"])
|
||||
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes other requests through", func(t *testing.T) {
|
||||
resp, err := client.Get("http://docker/v1.47/_ping")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
|
||||
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
||||
})
|
||||
|
||||
t.Run("tunnels raw streams", func(t *testing.T) {
|
||||
conn, err := net.Dial("unix", proxy.Socket)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n"))
|
||||
require.NoError(t, err)
|
||||
reader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(reader, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type"))
|
||||
echoed, err := reader.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{}ping\n", echoed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
||||
cli := &mockDockerClient{}
|
||||
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil)
|
||||
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
|
||||
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
||||
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil)
|
||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeLabelled(ctx, cli, "job-1"))
|
||||
cli.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerProxyWithDaemon(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||
direct, err := GetDockerClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer direct.Close()
|
||||
dir := shortTempDir(t)
|
||||
seen, err := daemonSeesDir(ctx, dir)
|
||||
require.NoError(t, err)
|
||||
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||
|
||||
job := "proxy-test-" + t.Name()
|
||||
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
||||
require.NoError(t, err)
|
||||
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||
require.NoError(t, err)
|
||||
defer viaProxy.Close()
|
||||
|
||||
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
|
||||
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||
require.NoError(t, err)
|
||||
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
||||
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
||||
|
||||
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
|
||||
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
|
||||
|
||||
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
||||
require.NoError(t, err)
|
||||
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
|
||||
require.NoError(t, err)
|
||||
_, err = attached.Conn.Write([]byte("hello\n"))
|
||||
require.NoError(t, err)
|
||||
echoed, err := attached.Reader.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello\r\n", echoed)
|
||||
attached.Close()
|
||||
|
||||
require.NoError(t, proxy.Close(ctx))
|
||||
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||
assert.True(t, cerrdefs.IsNotFound(err))
|
||||
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
|
||||
assert.True(t, cerrdefs.IsNotFound(err))
|
||||
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||
assert.True(t, cerrdefs.IsNotFound(err))
|
||||
}
|
||||
|
||||
func BenchmarkDockerProxy(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
direct, err := GetDockerClient(ctx)
|
||||
require.NoError(b, err)
|
||||
defer direct.Close()
|
||||
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
b.Skipf("docker daemon unreachable: %v", err)
|
||||
}
|
||||
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
|
||||
require.NoError(b, err)
|
||||
defer func() { _ = proxy.Close(ctx) }()
|
||||
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||
require.NoError(b, err)
|
||||
defer viaProxy.Close()
|
||||
|
||||
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
|
||||
require.NoError(b, err)
|
||||
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
|
||||
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||
require.NoError(b, err)
|
||||
|
||||
var archive bytes.Buffer
|
||||
writer := tar.NewWriter(&archive)
|
||||
payload := make([]byte, 64<<20)
|
||||
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
|
||||
_, _ = writer.Write(payload)
|
||||
require.NoError(b, writer.Close())
|
||||
|
||||
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
|
||||
b.Run("ping/"+name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("copy64MiB/"+name, func(b *testing.B) {
|
||||
b.SetBytes(int64(archive.Len()))
|
||||
for b.Loop() {
|
||||
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -138,12 +138,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
||||
).IfNot(common.Dryrun)
|
||||
}
|
||||
|
||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return common.NewPipelineExecutor(
|
||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
||||
cr.connect(),
|
||||
cr.find(),
|
||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
||||
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
|
||||
func(ctx context.Context) error {
|
||||
// If this fails, then folders have wrong permissions on non root container
|
||||
if cr.UID != 0 || cr.GID != 0 {
|
||||
@@ -252,6 +252,10 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
||||
ID: inspect.ID,
|
||||
Health: HealthNone,
|
||||
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
||||
Mounts: make(map[string]string, len(inspect.Mounts)),
|
||||
}
|
||||
for _, mountPoint := range inspect.Mounts {
|
||||
info.Mounts[mountPoint.Destination] = mountPoint.Source
|
||||
}
|
||||
|
||||
if state := inspect.State; state != nil {
|
||||
@@ -940,7 +944,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||
@@ -981,9 +985,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
||||
}
|
||||
|
||||
fc := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
UID: cr.UID,
|
||||
|
||||
@@ -471,7 +471,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||
}
|
||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
@@ -801,6 +801,20 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
assert.Equal(t, HealthNone, info.Health)
|
||||
})
|
||||
|
||||
t.Run("reports the mount sources by container path", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
Mounts: []container.MountPoint{
|
||||
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
|
||||
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
||||
"/var/run/docker.sock": "/var/run/docker.sock",
|
||||
}, info.Mounts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
|
||||
@@ -29,6 +29,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
func DockerProxyDir(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||
return nil, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
|
||||
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
@@ -110,9 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
},
|
||||
|
||||
@@ -47,10 +47,32 @@ func TestCopyDir(t *testing.T) {
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCopyDirSkipGitDir(t *testing.T) {
|
||||
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "action")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "dest")
|
||||
e := &HostEnvironment{StdOut: os.Stdout}
|
||||
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
||||
|
||||
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||
if skipGitDir {
|
||||
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||
} else {
|
||||
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContainerArchive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -94,10 +94,11 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
||||
}
|
||||
|
||||
type FileCollector struct {
|
||||
Ignorer gitignore.Matcher
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
Handler Handler
|
||||
Ignorer gitignore.Matcher
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
SkipGitDir bool
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
func openGitIndex(path string) (*index.Index, error) {
|
||||
@@ -124,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
||||
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
||||
return nil
|
||||
}
|
||||
if fc.SkipGitDir && split[len(split)-1] == ".git" {
|
||||
if fi.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var entry *index.Entry
|
||||
if i != nil {
|
||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
||||
|
||||
@@ -32,7 +32,7 @@ type actionStep interface {
|
||||
step
|
||||
|
||||
getActionModel() *model.Action
|
||||
getCompositeRunContext(context.Context) *RunContext
|
||||
getCompositeRunContext(context.Context) (*RunContext, error)
|
||||
getCompositeSteps() *compositeSteps
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
||||
}
|
||||
|
||||
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
||||
|
||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionLocation := path.Join(actionDir, actionPath)
|
||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
@@ -352,23 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
}
|
||||
}
|
||||
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
|
||||
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
||||
}
|
||||
cmd, err := shellquote.Split(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ee, err := evalDockerEnv(ctx, step, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ee := evalDockerEnv(ctx, step, action)
|
||||
if action.Runs.Args != nil {
|
||||
// a fresh slice, the manifest is evaluated again for every stage
|
||||
cmd = make([]string, len(action.Runs.Args))
|
||||
for i, v := range action.Runs.Args {
|
||||
cmd[i] = ee.Interpolate(ctx, v)
|
||||
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate runs.args: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return common.NewPipelineExecutor(
|
||||
prepImage,
|
||||
stepContainer.Pull(forcePull),
|
||||
@@ -392,7 +406,11 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
||||
default:
|
||||
entrypoint = runs.Entrypoint
|
||||
if entrypoint == "" {
|
||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
||||
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
||||
}
|
||||
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
|
||||
return fields, nil
|
||||
}
|
||||
}
|
||||
@@ -405,19 +423,22 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
||||
}
|
||||
|
||||
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
||||
func evalDockerEnv(ctx context.Context, step step, action *model.Action) *expressionEvaluator {
|
||||
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
var err error
|
||||
inputs := make(map[string]string)
|
||||
eval := rc.NewExpressionEvaluator(ctx)
|
||||
// Set Defaults
|
||||
for k, input := range action.Inputs {
|
||||
inputs[k] = eval.Interpolate(ctx, input.Default)
|
||||
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
if stepModel.With != nil {
|
||||
for k, v := range stepModel.With {
|
||||
inputs[k] = eval.Interpolate(ctx, v)
|
||||
for k, v := range stepModel.With {
|
||||
if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
|
||||
return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
|
||||
}
|
||||
}
|
||||
mergeIntoMap(step, step.getEnv(), inputs)
|
||||
@@ -428,12 +449,14 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) *expres
|
||||
|
||||
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||
for k, v := range *step.getEnv() {
|
||||
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
|
||||
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
return ee
|
||||
return ee, nil
|
||||
}
|
||||
|
||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
|
||||
rc := step.getRunContext()
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
envList := make([]string, 0)
|
||||
@@ -443,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
binds, mounts, err := rc.GetBindsAndMounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
if rc.IsHostEnv() {
|
||||
networkMode = "default"
|
||||
}
|
||||
return ContainerNewContainer(&container.NewContainerInput{
|
||||
@@ -467,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
AutoRemove: true,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
}), nil
|
||||
}
|
||||
|
||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||
@@ -480,15 +506,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
|
||||
}
|
||||
}
|
||||
|
||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
|
||||
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
|
||||
if _, ok := (*env)[envKey]; !ok {
|
||||
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
|
||||
var err error
|
||||
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||
@@ -588,11 +618,14 @@ func runPreStep(step actionStep) common.Executor {
|
||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||
|
||||
x := action.Runs.Using
|
||||
if !x.IsComposite() {
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case x.IsNode():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -605,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
|
||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePre)
|
||||
|
||||
case x.IsComposite():
|
||||
if step.getCompositeSteps() == nil {
|
||||
step.getCompositeRunContext(ctx)
|
||||
if _, err := step.getCompositeRunContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
||||
@@ -621,9 +653,6 @@ func runPreStep(step actionStep) common.Executor {
|
||||
return errors.New("missing steps in composite action")
|
||||
|
||||
case x == model.ActionRunsUsingGo:
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
|
||||
env := make(map[string]string)
|
||||
stepEnv := *step.getEnv()
|
||||
for k, v := range stepEnv {
|
||||
@@ -47,14 +48,17 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
||||
env[envKey] = value
|
||||
} else {
|
||||
// defaults could contain expressions
|
||||
env[envKey] = ee.Interpolate(ctx, input.Default)
|
||||
var err error
|
||||
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
|
||||
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
gh := step.getGithubContext(ctx)
|
||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
||||
|
||||
return env
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
|
||||
@@ -66,8 +70,11 @@ func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
|
||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
|
||||
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// run with the global config but without secrets
|
||||
configCopy := *parent.Config
|
||||
@@ -87,20 +94,21 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &configCopy,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: parent.JobContainer,
|
||||
ActionPath: actionPath,
|
||||
GlobalEnv: parent.GlobalEnv,
|
||||
Masks: parent.Masks,
|
||||
ExtraPath: parent.ExtraPath,
|
||||
Parent: parent,
|
||||
EventJSON: parent.EventJSON,
|
||||
Config: &configCopy,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: parent.JobContainer,
|
||||
ActionPath: actionPath,
|
||||
GlobalEnv: parent.GlobalEnv,
|
||||
Masks: parent.Masks,
|
||||
ExtraPath: parent.ExtraPath,
|
||||
Parent: parent,
|
||||
EventJSON: parent.EventJSON,
|
||||
platformImage: parent.platformImage,
|
||||
}
|
||||
compositerc.setCompositeActionEnv(env)
|
||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||
|
||||
return compositerc
|
||||
return compositerc, nil
|
||||
}
|
||||
|
||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||
@@ -121,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor {
|
||||
action := step.getActionModel()
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
compositeRC := step.getCompositeRunContext(ctx)
|
||||
compositeRC, err := step.getCompositeRunContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
steps := step.getCompositeSteps()
|
||||
|
||||
@@ -131,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
|
||||
|
||||
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
||||
|
||||
err := steps.main(ctx)
|
||||
err = steps.main(ctx)
|
||||
|
||||
// Map outputs from composite RunContext to job RunContext
|
||||
eval := compositeRC.NewExpressionEvaluator(ctx)
|
||||
for outputName, output := range action.Outputs {
|
||||
rc.setOutput(ctx, map[string]string{
|
||||
"name": outputName,
|
||||
}, eval.Interpolate(ctx, output.Value))
|
||||
value, outputErr := eval.Interpolate(ctx, output.Value)
|
||||
if outputErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
|
||||
continue
|
||||
}
|
||||
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
|
||||
}
|
||||
|
||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||
|
||||
@@ -19,25 +19,30 @@ func TestCompositeActionParity(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
strategy := &model.Strategy{MaxParallel: 3}
|
||||
parent := &RunContext{
|
||||
Config: &Config{},
|
||||
Matrix: map[string]any{"os": "linux"},
|
||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||
JobContainer: &jobContainerMock{},
|
||||
Config: &Config{},
|
||||
Matrix: map[string]any{"os": "linux"},
|
||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||
JobContainer: &jobContainerMock{},
|
||||
platformImage: "-self-hosted",
|
||||
}
|
||||
composite := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
||||
RunContext: parent,
|
||||
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
|
||||
env: map[string]string{"INPUT_SHARED": "outer"},
|
||||
}, "/action")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
||||
assert.Equal(t, "linux|3|outer", composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}"))
|
||||
assert.True(t, composite.IsHostEnv())
|
||||
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "linux|3|outer", interpolated)
|
||||
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||
|
||||
nestedEnv := composite.GetEnv()
|
||||
populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite)
|
||||
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
|
||||
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
||||
})
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestActionRunner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
|
||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "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 {
|
||||
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||
step.On("getEnv").Return(&env)
|
||||
|
||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
||||
_, _ = 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)
|
||||
@@ -337,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
copyEntered := make(chan struct{})
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
|
||||
close(copyEntered)
|
||||
<-releaseCopy
|
||||
return nil
|
||||
|
||||
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
args := cm.Called(destPath, srcPath, useGitIgnore)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
}
|
||||
|
||||
ghc := rc.getGithubContext(ctx)
|
||||
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc)
|
||||
inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: ghc,
|
||||
@@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
||||
// todo: should be unavailable
|
||||
// but required to interpolate/evaluate the step outputs on the job
|
||||
Steps: rc.getStepsContext(),
|
||||
Secrets: getWorkflowSecrets(ctx, rc),
|
||||
Secrets: getWorkflowSecrets(rc),
|
||||
Vars: getWorkflowVars(ctx, rc),
|
||||
Strategy: strategy,
|
||||
Matrix: rc.Matrix,
|
||||
@@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
||||
Env: *step.getEnv(),
|
||||
Job: rc.getJobContext(),
|
||||
Steps: rc.getStepsContext(),
|
||||
Secrets: getWorkflowSecrets(ctx, rc),
|
||||
Secrets: getWorkflowSecrets(rc),
|
||||
Vars: getWorkflowVars(ctx, rc),
|
||||
Strategy: strategy,
|
||||
Matrix: rc.Matrix,
|
||||
Needs: using,
|
||||
// todo: should be unavailable
|
||||
// but required to interpolate/evaluate the inputs in actions/composite
|
||||
Inputs: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)),
|
||||
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
||||
HashFiles: getHashFilesFunction(ctx, rc),
|
||||
}
|
||||
ee.Runner = rc.getRunnerContext(ctx)
|
||||
@@ -246,17 +247,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
|
||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||
out, err := ee.interpolate(ctx, in)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
|
||||
return ee.shared(ctx).Interpolate(in)
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||
return ee.shared(ctx).Interpolate(in)
|
||||
// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
|
||||
func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
|
||||
out, err := ee.Interpolate(ctx, in)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
|
||||
return in
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||
@@ -277,10 +279,10 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
||||
return inputs
|
||||
}
|
||||
|
||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||
inputs := map[string]any{}
|
||||
|
||||
setupWorkflowInputs(ctx, &inputs, rc)
|
||||
maps.Copy(inputs, rc.workflowCallInputs)
|
||||
maps.Copy(inputs, stepInputs)
|
||||
|
||||
if ghc.EventName == "workflow_dispatch" {
|
||||
@@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any {
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
||||
if rc.caller != nil {
|
||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
||||
func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
||||
if rc.caller == nil {
|
||||
return nil
|
||||
}
|
||||
callerJob := rc.caller.runContext.Run.Job()
|
||||
callerEval := rc.caller.runContext.ExprEval
|
||||
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||
|
||||
for name, input := range config.Inputs {
|
||||
value := rc.caller.runContext.Run.Job().With[name]
|
||||
if value != nil {
|
||||
if str, ok := value.(string); ok {
|
||||
// evaluate using the calling RunContext (outside)
|
||||
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
|
||||
}
|
||||
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
|
||||
for name, input := range config.Inputs {
|
||||
value, eval, label := callerJob.With[name], callerEval, "input"
|
||||
if value == nil {
|
||||
value, eval, label = input.Default, calleeEval(), "the default of input"
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
var err error
|
||||
if value, err = eval.Interpolate(ctx, str); err != nil {
|
||||
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
|
||||
}
|
||||
}
|
||||
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
|
||||
}
|
||||
|
||||
if value == nil && config != nil && config.Inputs != nil {
|
||||
value = input.Default
|
||||
if rc.ExprEval != nil {
|
||||
if str, ok := value.(string); ok {
|
||||
// evaluate using the called RunContext (inside)
|
||||
value = rc.ExprEval.Interpolate(ctx, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
||||
secrets := callerJob.Secrets()
|
||||
if secrets == nil && callerJob.InheritSecrets() {
|
||||
secrets = rc.caller.runContext.Config.Secrets
|
||||
}
|
||||
rc.workflowCallSecrets = make(map[string]string, len(secrets))
|
||||
for k, v := range secrets {
|
||||
var err error
|
||||
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
||||
func getWorkflowSecrets(rc *RunContext) map[string]string {
|
||||
if rc.caller != nil {
|
||||
job := rc.caller.runContext.Run.Job()
|
||||
secrets := job.Secrets()
|
||||
|
||||
if secrets == nil && job.InheritSecrets() {
|
||||
secrets = rc.caller.runContext.Config.Secrets
|
||||
}
|
||||
|
||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
||||
// in place is a data race.
|
||||
interpolated := make(map[string]string, len(secrets))
|
||||
for k, v := range secrets {
|
||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
|
||||
return interpolated
|
||||
return rc.workflowCallSecrets
|
||||
}
|
||||
|
||||
return rc.Config.Secrets
|
||||
}
|
||||
|
||||
|
||||
@@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) {
|
||||
{"${{ null }}", ""},
|
||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
||||
{"${{ 1) && (2 }}", ""},
|
||||
{"run ${{ 1) && (2 }} now", ""},
|
||||
{"${{ 1", "${{ 1"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run("interpolate", func(t *testing.T) {
|
||||
assertObject := assert.New(t)
|
||||
out := ee.Interpolate(context.Background(), table.in)
|
||||
assertObject.Equal(table.out, out, table.in)
|
||||
out, err := ee.Interpolate(context.Background(), table.in)
|
||||
require.NoError(t, err, table.in)
|
||||
assert.Equal(t, table.out, out, table.in)
|
||||
})
|
||||
}
|
||||
|
||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
|
||||
_, err := ee.Interpolate(context.Background(), in)
|
||||
assert.Error(t, err, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||
@@ -352,7 +354,7 @@ on:
|
||||
}
|
||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||
|
||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
||||
inputs := getEvaluatorInputs(rc, nil, ghc)
|
||||
assert.Equal(t, table.flag, inputs["flag"])
|
||||
assert.Equal(t, "gitea", inputs["name"])
|
||||
})
|
||||
@@ -372,7 +374,8 @@ jobs:
|
||||
|
||||
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
||||
containerName := func(jobID string) string {
|
||||
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
||||
rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, rc.Name, "s3cr3t")
|
||||
return rc.jobContainerName()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
if rc.Run == nil {
|
||||
return nil
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
// evaluate environment variables since they can contain
|
||||
// GitHub's special environment variables.
|
||||
for k, v := range rc.GetEnv() {
|
||||
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
|
||||
if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -240,6 +239,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
||||
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
} else if err := setJobOutputs(ctx, rc); err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -258,7 +259,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||
}
|
||||
setJobResult(ctx, info, rc, jobError == nil)
|
||||
setJobOutputs(ctx, rc)
|
||||
|
||||
return err
|
||||
})
|
||||
@@ -413,30 +413,58 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||
}
|
||||
|
||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||
if rc.caller != nil {
|
||||
// map outputs for reusable workflows
|
||||
callerOutputs := make(map[string]string)
|
||||
|
||||
ee := rc.NewExpressionEvaluator(ctx)
|
||||
|
||||
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
|
||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
|
||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
var err error
|
||||
for k, v := range rc.GetEnv() {
|
||||
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
}
|
||||
|
||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||
// serialize the write so parallel combos don't race on its Outputs field.
|
||||
callerJob := rc.caller.runContext.Run.Job()
|
||||
defer lockJob(callerJob)()
|
||||
callerJob.Outputs = callerOutputs
|
||||
}
|
||||
defaults := rc.Run.Job().Defaults.Run
|
||||
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
|
||||
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
|
||||
}
|
||||
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
|
||||
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||
if rc.caller == nil {
|
||||
return nil
|
||||
}
|
||||
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
|
||||
callerOutputs := make(map[string]string, len(outputs))
|
||||
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||
for k, v := range outputs {
|
||||
value := v.Value
|
||||
for range 2 { // two passes, the value resolves through a job output
|
||||
var err error
|
||||
if value, err = ee().Interpolate(ctx, value); err != nil {
|
||||
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
callerOutputs[k] = value
|
||||
}
|
||||
|
||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||
// serialize the write so parallel combos don't race on its Outputs field.
|
||||
callerJob := rc.caller.runContext.Run.Job()
|
||||
defer lockJob(callerJob)()
|
||||
callerJob.Outputs = callerOutputs
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||
// step-level evaluateStepTimeout in step.go.
|
||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||
if timeout != "" {
|
||||
timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
||||
} else if timeout != "" {
|
||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||
}
|
||||
@@ -635,7 +663,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
||||
|
||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||
|
||||
var copied string
|
||||
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
|
||||
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
|
||||
body, err := os.ReadFile(script)
|
||||
require.NoError(t, err)
|
||||
copied = string(body)
|
||||
|
||||
@@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||
}
|
||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
URL: interpolatedURL,
|
||||
Ref: ref,
|
||||
Dir: targetDirectory,
|
||||
Token: token,
|
||||
|
||||
@@ -67,7 +67,11 @@ type RunContext struct {
|
||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||
Masks []string
|
||||
cleanUpJobContainer common.Executor
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
|
||||
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
||||
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||
// have already been created on the JobContainer. The runner sets up file-command files
|
||||
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||
@@ -92,6 +96,7 @@ type RunContext struct {
|
||||
jobContainerID string
|
||||
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
|
||||
jobNetworkName string
|
||||
dockerProxy *container.DockerProxy
|
||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||
@@ -246,11 +251,41 @@ func (rc *RunContext) validVolumes() []string {
|
||||
if rc.Config.SharedToolCache {
|
||||
volumes = append(volumes, sharedToolCacheVolume)
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
volumes = append(volumes, rc.Config.Workdir)
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
volumes = append(volumes, rc.dockerProxy.Socket)
|
||||
}
|
||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||
return append(volumes, name, name+"-env",
|
||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||
}
|
||||
|
||||
func (rc *RunContext) jobDockerSocket() string {
|
||||
if rc.dockerProxy != nil {
|
||||
return rc.dockerProxy.Socket
|
||||
}
|
||||
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
|
||||
}
|
||||
|
||||
func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
||||
daemonSocket := rc.containerDaemonSocket()
|
||||
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
|
||||
return
|
||||
}
|
||||
dir := container.DockerProxyDir(ctx)
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||
return
|
||||
}
|
||||
rc.dockerProxy = proxy
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
@@ -299,7 +334,7 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
||||
}
|
||||
|
||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
||||
name := rc.jobContainerName()
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
@@ -308,7 +343,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
if container := job.Container(); container != nil {
|
||||
for _, v := range container.Volumes {
|
||||
if rc.ExprEval != nil {
|
||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||
var err error
|
||||
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
|
||||
}
|
||||
}
|
||||
volumes = append(volumes, v)
|
||||
}
|
||||
@@ -317,8 +355,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
// the runner's own mounts below yield to the targets the job claims
|
||||
binds, mounts, claimed := splitVolumes(volumes)
|
||||
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
|
||||
}
|
||||
if rc.Config.SharedToolCache {
|
||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||
@@ -342,7 +380,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
return binds, mounts
|
||||
return binds, mounts, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
@@ -433,7 +471,7 @@ var newContainer = container.NewContainer
|
||||
func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
image := rc.platformImage(ctx)
|
||||
image := rc.platformImage
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
|
||||
username, password, err := rc.handleCredentials(ctx)
|
||||
@@ -445,6 +483,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||
// We need to set the container name to the environment variable.
|
||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||
rc.startDockerProxy(ctx)
|
||||
|
||||
envList := make([]string, 0)
|
||||
|
||||
@@ -452,7 +491,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
binds, mounts, err := rc.GetBindsAndMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
||||
// if using service containers, will create a new network for the containers.
|
||||
@@ -463,7 +505,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
for serviceID, spec := range rc.Run.Job().Services {
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
|
||||
}
|
||||
if serviceImage == "" {
|
||||
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||
continue
|
||||
@@ -473,16 +518,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// a service reaches the internet the way the job does; its own env still wins
|
||||
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
||||
for k, v := range spec.Env {
|
||||
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
|
||||
if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
|
||||
}
|
||||
}
|
||||
envs := make([]string, 0, len(interpolatedEnvs))
|
||||
for k, v := range interpolatedEnvs {
|
||||
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// interpolate cmd
|
||||
interpolatedCmd := make([]string, 0, len(spec.Cmd))
|
||||
for _, v := range spec.Cmd {
|
||||
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
|
||||
interpolatedCmd := make([]string, len(spec.Cmd))
|
||||
for i, v := range spec.Cmd {
|
||||
if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
|
||||
}
|
||||
}
|
||||
// keep these local: reusing username/password would overwrite the
|
||||
// credentials the job container is pulled with further down
|
||||
@@ -491,20 +540,28 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||
}
|
||||
|
||||
interpolatedVolumes := make([]string, 0, len(spec.Volumes))
|
||||
for _, volume := range spec.Volumes {
|
||||
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume))
|
||||
interpolatedVolumes := make([]string, len(spec.Volumes))
|
||||
for i, volume := range spec.Volumes {
|
||||
if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
|
||||
}
|
||||
}
|
||||
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
|
||||
|
||||
interpolatedPorts := make([]string, 0, len(spec.Ports))
|
||||
for _, port := range spec.Ports {
|
||||
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port))
|
||||
interpolatedPorts := make([]string, len(spec.Ports))
|
||||
for i, port := range spec.Ports {
|
||||
if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
|
||||
}
|
||||
}
|
||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||
}
|
||||
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
|
||||
}
|
||||
|
||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||
c := newContainer(&container.NewContainerInput{
|
||||
@@ -522,7 +579,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
UsernsMode: rc.Config.UsernsMode,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options),
|
||||
WorkflowOptions: serviceOptions,
|
||||
NetworkMode: networkName,
|
||||
NetworkAliases: []string{serviceID},
|
||||
ExposedPorts: exposedPorts,
|
||||
@@ -538,6 +595,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||
jobContainerNetwork := networkName
|
||||
|
||||
workflowOptions, err := rc.workflowOptions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||
@@ -557,7 +618,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
UsernsMode: rc.Config.UsernsMode,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
RunnerOptions: rc.Config.ContainerOptions,
|
||||
WorkflowOptions: rc.workflowOptions(ctx),
|
||||
WorkflowOptions: workflowOptions,
|
||||
AutoRemove: true,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
@@ -619,6 +680,12 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
if err := rc.dockerProxy.Close(ctx); err != nil {
|
||||
logger.Errorf("Error while removing what the job created: %v", err)
|
||||
}
|
||||
rc.dockerProxy = nil
|
||||
}
|
||||
if removeJobContainer {
|
||||
// after the containers using them, services can hold these via `--volumes-from`
|
||||
name := rc.jobContainerName()
|
||||
@@ -882,6 +949,9 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||
return nil
|
||||
}
|
||||
rc.jobContainerID = info.ID
|
||||
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
|
||||
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -928,7 +998,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
outputs := make(map[string]string, len(rc.outputTemplate))
|
||||
var err error
|
||||
for k, v := range rc.outputTemplate {
|
||||
if outputs[k], err = ee.interpolate(ctx, v); err != nil {
|
||||
if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
|
||||
break
|
||||
}
|
||||
@@ -948,7 +1018,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
func (rc *RunContext) startContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
var err error
|
||||
if rc.IsHostEnv(ctx) {
|
||||
if rc.IsHostEnv() {
|
||||
err = rc.startHostEnvironment()(ctx)
|
||||
} else {
|
||||
err = rc.startJobContainer()(ctx)
|
||||
@@ -978,10 +1048,8 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) IsHostEnv(ctx context.Context) bool {
|
||||
platform := rc.runsOnImage(ctx)
|
||||
image := rc.containerImage(ctx)
|
||||
return image == "" && strings.EqualFold(platform, "-self-hosted")
|
||||
func (rc *RunContext) IsHostEnv() bool {
|
||||
return strings.EqualFold(rc.platformImage, "-self-hosted")
|
||||
}
|
||||
|
||||
func (rc *RunContext) stopContainer() common.Executor {
|
||||
@@ -1066,32 +1134,35 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) containerImage(ctx context.Context) string {
|
||||
job := rc.Run.Job()
|
||||
|
||||
c := job.Container()
|
||||
if c != nil {
|
||||
return rc.ExprEval.Interpolate(ctx, c.Image)
|
||||
func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
|
||||
c := rc.Run.Job().Container()
|
||||
if c == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return ""
|
||||
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) runsOnImage(ctx context.Context) string {
|
||||
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||
}
|
||||
|
||||
job := rc.Run.Job()
|
||||
runsOn := job.RunsOn()
|
||||
runsOn := rc.Run.Job().RunsOn()
|
||||
for i, v := range runsOn {
|
||||
runsOn[i] = rc.ExprEval.Interpolate(ctx, v)
|
||||
var err error
|
||||
if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||
return "", fmt.Errorf("unable to interpolate runs-on: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if rc.Config.PlatformPicker != nil {
|
||||
return rc.Config.PlatformPicker(runsOn)
|
||||
return rc.Config.PlatformPicker(runsOn), nil
|
||||
}
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
@@ -1112,21 +1183,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
return model.RunsOnFromNode(rawRunsOn)
|
||||
}
|
||||
|
||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
||||
if containerImage := rc.containerImage(ctx); containerImage != "" {
|
||||
return containerImage
|
||||
// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
|
||||
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
||||
image, err := rc.containerImage(ctx)
|
||||
if err == nil && image == "" {
|
||||
image, err = rc.runsOnImage(ctx)
|
||||
}
|
||||
|
||||
return rc.runsOnImage(ctx)
|
||||
rc.platformImage = image
|
||||
return err
|
||||
}
|
||||
|
||||
func (rc *RunContext) workflowOptions(ctx context.Context) string {
|
||||
func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
|
||||
c := rc.Run.Job().Container()
|
||||
if c == nil {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return rc.ExprEval.Interpolate(ctx, c.Options)
|
||||
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to interpolate container.options: %w", err)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||
@@ -1156,8 +1232,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
img := rc.platformImage(ctx)
|
||||
if img == "" {
|
||||
if err := rc.resolvePlatformImage(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rc.platformImage == "" {
|
||||
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
||||
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
|
||||
}
|
||||
@@ -1544,7 +1622,7 @@ func (rc *RunContext) imageOS(ctx context.Context) string {
|
||||
// log that runs-on is missing.
|
||||
return ""
|
||||
}
|
||||
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" {
|
||||
if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
|
||||
return imageOS
|
||||
}
|
||||
|
||||
@@ -1606,14 +1684,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma
|
||||
}
|
||||
|
||||
ee := rc.NewExpressionEvaluator(ctx)
|
||||
username := ee.Interpolate(ctx, credentials["username"])
|
||||
if username == "" {
|
||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username")
|
||||
interpolate := func(key string) (string, error) {
|
||||
value, err := ee.Interpolate(ctx, credentials[key])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
password := ee.Interpolate(ctx, credentials["password"])
|
||||
if password == "" {
|
||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password")
|
||||
username, err := interpolate("username")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password, err := interpolate("password")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
||||
},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
|
||||
// the inputs are built before the missing daemon fails the first call
|
||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||
@@ -451,7 +452,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
config := testcase.rc.Config
|
||||
config.Workdir = testcase.name
|
||||
config.BindWorkdir = bindWorkDir
|
||||
gotbind, gotmount := rctemplate.GetBindsAndMounts()
|
||||
gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Name binds/mounts are either/or
|
||||
if config.BindWorkdir {
|
||||
@@ -510,7 +512,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||
@@ -539,7 +542,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.Run.JobID = "job1"
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(testcase.wantbind) > 0 {
|
||||
assert.Contains(t, gotbind, testcase.wantbind)
|
||||
@@ -574,11 +578,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
Config: &Config{},
|
||||
}
|
||||
|
||||
_, gotmount := rc.GetBindsAndMounts()
|
||||
_, gotmount, err := rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||
|
||||
rc.Config.SharedToolCache = true
|
||||
_, gotmount = rc.GetBindsAndMounts()
|
||||
_, gotmount, err = rc.GetBindsAndMounts()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||
})
|
||||
}
|
||||
@@ -604,6 +610,11 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
// a job may mount it only while the runner does
|
||||
rc.Config.SharedToolCache = false
|
||||
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
|
||||
|
||||
rc.Config.Workdir = "/workspace/1/owner/repo"
|
||||
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||
rc.Config.BindWorkdir = true
|
||||
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
@@ -655,8 +666,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
||||
r := &runnerImpl{config: &Config{}}
|
||||
ctx := context.Background()
|
||||
|
||||
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
||||
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
||||
rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
||||
require.NoError(t, err)
|
||||
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
||||
@@ -1281,6 +1294,29 @@ func TestGetJobContextReportsContainers(t *testing.T) {
|
||||
}, jobContext.Services)
|
||||
}
|
||||
|
||||
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
||||
job := &containerMock{}
|
||||
job.On("Inspect", mock.Anything).Return(&container.Info{
|
||||
ID: "job-container-id",
|
||||
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"},
|
||||
}, nil)
|
||||
rc := &RunContext{
|
||||
Config: &Config{Workdir: "/workspace/owner/repo/"},
|
||||
Env: map[string]string{},
|
||||
JobContainer: job,
|
||||
}
|
||||
|
||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
||||
|
||||
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
||||
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
||||
|
||||
rc.Config.Workdir = "/elsewhere"
|
||||
rc.Env = map[string]string{}
|
||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
||||
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
|
||||
}
|
||||
|
||||
// A job that never started a container reports an empty context, not a placeholder.
|
||||
func TestGetJobContextWithoutContainer(t *testing.T) {
|
||||
jobContext := (&RunContext{}).getJobContext()
|
||||
@@ -1329,12 +1365,14 @@ func TestRunContextImageOS(t *testing.T) {
|
||||
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
|
||||
require.NoError(t, rc.resolvePlatformImage(ctx))
|
||||
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
||||
})
|
||||
|
||||
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
||||
rc.Config.PlatformPicker = func([]string) string { return "some-image" }
|
||||
require.NoError(t, rc.resolvePlatformImage(ctx))
|
||||
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
||||
})
|
||||
|
||||
|
||||
@@ -198,7 +198,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
|
||||
strategyRc := runner.newRunContext(ctx, run, nil)
|
||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||
@@ -230,7 +233,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||
|
||||
for i, matrix := range matrixes {
|
||||
rc := runner.newRunContext(ctx, run, matrix)
|
||||
rc, err := runner.newRunContext(ctx, run, matrix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc.JobName = rc.Name
|
||||
if len(matrixes) > 1 {
|
||||
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
||||
@@ -315,7 +321,7 @@ func handleFailure(plan *model.Plan) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext {
|
||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
|
||||
rc := &RunContext{
|
||||
Config: runner.config,
|
||||
Run: run,
|
||||
@@ -324,15 +330,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
||||
Matrix: matrix,
|
||||
caller: runner.caller,
|
||||
}
|
||||
if err := rc.resolveWorkflowCall(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String()))
|
||||
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
|
||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
||||
if job := run.Job(); job != nil {
|
||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
||||
}
|
||||
|
||||
return rc
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -82,9 +83,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
rc.StepResults[rc.CurrentStep] = stepResult
|
||||
}
|
||||
|
||||
setupEnv(ctx, step)
|
||||
|
||||
runStep, err := isStepEnabled(ctx, ifExpression, step, stage)
|
||||
err := setupEnv(ctx, step)
|
||||
var runStep bool
|
||||
if err == nil {
|
||||
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||
}
|
||||
if err != nil {
|
||||
stepResult.Conclusion = model.StepStatusFailure
|
||||
stepResult.Outcome = model.StepStatusFailure
|
||||
@@ -99,7 +102,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
return nil
|
||||
}
|
||||
|
||||
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String())
|
||||
stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
|
||||
if strings.Contains(stepString, "::add-mask::") {
|
||||
stepString = "add-mask command"
|
||||
}
|
||||
@@ -221,8 +224,10 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
}
|
||||
|
||||
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
||||
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
||||
if timeout != "" {
|
||||
timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
|
||||
} else if timeout != "" {
|
||||
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
|
||||
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
||||
}
|
||||
@@ -230,27 +235,33 @@ func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, ste
|
||||
return ctx, func() {}
|
||||
}
|
||||
|
||||
func setupEnv(ctx context.Context, step step) {
|
||||
func setupEnv(ctx context.Context, step step) error {
|
||||
rc := step.getRunContext()
|
||||
|
||||
mergeEnv(ctx, step)
|
||||
// merge step env last, since it should not be overwritten
|
||||
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
||||
|
||||
var err error
|
||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||
for k, v := range *step.getEnv() {
|
||||
if !strings.HasPrefix(k, "INPUT_") {
|
||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
||||
if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
||||
// you can use step level env in the with property of a uses construct
|
||||
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv())
|
||||
inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) })
|
||||
for k, v := range *step.getEnv() {
|
||||
if strings.HasPrefix(k, "INPUT_") {
|
||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
||||
if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEnv(ctx context.Context, step step) {
|
||||
|
||||
@@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
|
||||
return sal.action
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||
if sal.compositeRunContext == nil {
|
||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
||||
|
||||
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action)
|
||||
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sal.compositeRunContext = compositeRunContext
|
||||
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
|
||||
}
|
||||
return sal.compositeRunContext
|
||||
return sal.compositeRunContext, nil
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
||||
|
||||
@@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
// Since actions can specify the download source via a url prefix.
|
||||
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
||||
// so we need to interpolate the expression value for uses first.
|
||||
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||
uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate uses: %w", err)
|
||||
}
|
||||
sar.Step.Uses = uses
|
||||
|
||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
||||
@@ -160,9 +164,12 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
||||
return nil
|
||||
}
|
||||
eval := sar.RunContext.NewExpressionEvaluator(ctx)
|
||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"]))
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate with.path: %w", err)
|
||||
}
|
||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
||||
}
|
||||
|
||||
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
|
||||
@@ -229,14 +236,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
return sar.action
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
|
||||
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action)
|
||||
compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sar.compositeRunContext = compositeRunContext
|
||||
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
|
||||
} else {
|
||||
// Re-evaluate environment here. For remote actions the environment
|
||||
// need to be re-created for every stage (pre, main, post) as there
|
||||
@@ -244,11 +255,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon
|
||||
// stages are executed. (e.g. the output of another action is the
|
||||
// input for this action during the main stage, but the env
|
||||
// was already created during the pre stage)
|
||||
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
||||
env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sar.compositeRunContext.setCompositeActionEnv(env)
|
||||
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
||||
}
|
||||
return sar.compositeRunContext
|
||||
return sar.compositeRunContext, nil
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||
|
||||
@@ -254,7 +254,8 @@ func TestStepActionRemote(t *testing.T) {
|
||||
|
||||
for _, value := range []string{"first", "second"} {
|
||||
step.env["INPUT_SHARED"] = value
|
||||
composite := step.getCompositeRunContext(t.Context())
|
||||
composite, err := step.getCompositeRunContext(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
|
||||
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||
eval := rc.NewExpressionEvaluator(ctx)
|
||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"]))
|
||||
args, err := eval.Interpolate(ctx, step.With["args"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
||||
}
|
||||
cmd, err := shellquote.Split(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var entrypoint []string
|
||||
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" {
|
||||
entry, err := eval.Interpolate(ctx, step.With["entrypoint"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
||||
}
|
||||
if entry != "" {
|
||||
entrypoint = []string{entry}
|
||||
}
|
||||
|
||||
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
||||
stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
stepContainer.Pull(rc.Config.ForcePull),
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStepDockerMain(t *testing.T) {
|
||||
@@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
_ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
||||
_, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
||||
})
|
||||
}
|
||||
@@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
||||
"IsHostEnv mismatch for platform %q", tc.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, "")
|
||||
_, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
||||
|
||||
if tc.expectDefault {
|
||||
assert.Equal(t, "default", captured.NetworkMode,
|
||||
|
||||
@@ -254,12 +254,21 @@ func getScriptName(rc *RunContext, step *model.Step) string {
|
||||
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
|
||||
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
implicitShell := sr.setupShell(ctx)
|
||||
sr.setupWorkingDirectory(ctx)
|
||||
eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr)
|
||||
implicitShell, err := sr.setupShell(ctx, eval)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := sr.setupWorkingDirectory(ctx, eval); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
step := sr.Step
|
||||
|
||||
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
||||
script, err = eval.Interpolate(ctx, step.Run)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to interpolate the run script: %w", err)
|
||||
}
|
||||
sr.interpolatedScript = script
|
||||
|
||||
// GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid
|
||||
@@ -313,19 +322,21 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
|
||||
return name, script, err
|
||||
}
|
||||
|
||||
func (sr *stepRun) setupShell(ctx context.Context) bool {
|
||||
func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (bool, error) {
|
||||
rc := sr.RunContext
|
||||
step := sr.Step
|
||||
|
||||
if step.Shell == "" {
|
||||
step.Shell = rc.Run.Job().Defaults.Run.Shell
|
||||
shell, err := eval.Interpolate(ctx, step.Shell)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to interpolate the shell: %w", err)
|
||||
}
|
||||
|
||||
step.Shell = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Shell)
|
||||
|
||||
if step.Shell == "" {
|
||||
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
|
||||
if shell == "" {
|
||||
shell = rc.jobRunDefaults.Shell
|
||||
}
|
||||
if shell == "" {
|
||||
shell = rc.Run.Workflow.Defaults.Run.Shell
|
||||
}
|
||||
step.Shell = shell
|
||||
|
||||
implicitShell := step.Shell == ""
|
||||
if implicitShell {
|
||||
@@ -349,7 +360,7 @@ func (sr *stepRun) setupShell(ctx context.Context) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return implicitShell
|
||||
return implicitShell, nil
|
||||
}
|
||||
|
||||
// containerHasBash probes once per job, else every implicit-shell step pays for an exec.
|
||||
@@ -364,23 +375,19 @@ func (rc *RunContext) containerHasBash(ctx context.Context, env map[string]strin
|
||||
return *top.hasBash
|
||||
}
|
||||
|
||||
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) {
|
||||
func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error {
|
||||
rc := sr.RunContext
|
||||
step := sr.Step
|
||||
var workingdirectory string
|
||||
|
||||
if step.WorkingDirectory == "" {
|
||||
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory
|
||||
} else {
|
||||
workingdirectory = step.WorkingDirectory
|
||||
workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate the working directory: %w", err)
|
||||
}
|
||||
|
||||
// jobs can receive context values, so we interpolate
|
||||
workingdirectory = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, workingdirectory)
|
||||
|
||||
// but top level keys in workflow file like `defaults` or `env` can't
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
@@ -42,17 +43,12 @@ func TestStepRun(t *testing.T) {
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {
|
||||
Defaults: model.Defaults{
|
||||
Run: model.RunDefaults{
|
||||
Shell: "bash",
|
||||
},
|
||||
},
|
||||
},
|
||||
"1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
JobContainer: cm,
|
||||
jobRunDefaults: model.RunDefaults{Shell: "bash"},
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
@@ -84,13 +80,13 @@ func TestStepRun(t *testing.T) {
|
||||
|
||||
func TestStepRunShellParity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, shell, workingDir string
|
||||
env map[string]string
|
||||
host bool
|
||||
probeErr error
|
||||
wantExt string
|
||||
wantCmd []string
|
||||
wantErr string
|
||||
name, run, shell, workingDir string
|
||||
env map[string]string
|
||||
host bool
|
||||
probeErr error
|
||||
wantExt string
|
||||
wantCmd []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "implicit host bash",
|
||||
@@ -130,6 +126,21 @@ func TestStepRunShellParity(t *testing.T) {
|
||||
wantExt: ".py",
|
||||
wantCmd: []string{"python", "/var/run/act/workflow/1.py"},
|
||||
},
|
||||
{
|
||||
name: "run expression without a context",
|
||||
run: "echo ${{ COMMIT_SHA }}",
|
||||
wantErr: "unable to interpolate the run script:",
|
||||
},
|
||||
{
|
||||
name: "shell expression without a context",
|
||||
shell: "${{ SHELL }}",
|
||||
wantErr: "unable to interpolate the shell:",
|
||||
},
|
||||
{
|
||||
name: "working directory expression without a context",
|
||||
workingDir: "${{ DIR }}",
|
||||
wantErr: "unable to interpolate the working directory:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -156,13 +167,13 @@ func TestStepRunShellParity(t *testing.T) {
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: jobContainer,
|
||||
},
|
||||
Step: &model.Step{ID: "1", Run: "echo hi", Shell: test.shell, WorkingDirectory: test.workingDir},
|
||||
Step: &model.Step{ID: "1", Run: cmp.Or(test.run, "echo hi"), Shell: test.shell, WorkingDirectory: test.workingDir},
|
||||
env: test.env,
|
||||
}
|
||||
|
||||
name, script, err := sr.setupShellCommand(t.Context())
|
||||
if test.wantErr != "" {
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
require.ErrorContains(t, err, test.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestSetupEnv(t *testing.T) {
|
||||
sm.On("getStepModel").Return(step)
|
||||
sm.On("getEnv").Return(&env)
|
||||
|
||||
setupEnv(context.Background(), sm)
|
||||
require.NoError(t, setupEnv(context.Background(), sm))
|
||||
|
||||
// These are commit or system specific
|
||||
delete(env, "GITHUB_REF")
|
||||
|
||||
2
go.mod
2
go.mod
@@ -102,6 +102,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -234,8 +234,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
|
||||
@@ -256,11 +256,10 @@ container:
|
||||
#require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
#docker_timeout: 0s
|
||||
# Bind the workspace to the host filesystem instead of using Docker volumes.
|
||||
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
||||
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
||||
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
||||
# directory is also mounted into the runner container and listed in valid_volumes.
|
||||
# Mount the workspace from a host directory instead of a Docker volume, so jobs
|
||||
# can bind-mount it by its own path into sibling containers (".:/app" in docker
|
||||
# compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable.
|
||||
# The workspace parent directory must be mounted into the runner container.
|
||||
#bind_workdir: false
|
||||
# How long a job waits for a service container that declares a healthcheck to become
|
||||
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
||||
|
||||
@@ -157,7 +157,7 @@ type Container struct {
|
||||
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
|
||||
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
|
||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
|
||||
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user