Skip to content

Commit f7a846d

Browse files
ChristopherHXZauberNerdmergify[bot]
authored
feat: cli option to enable the new action cache (#1954)
* Enable the new action cache * fix * fix: CopyTarStream (Docker) * suppress panic in test * add a cli option for opt in * fixups * add package * fix * rc.Config nil in test??? * add feature flag * patch * Fix respect --action-cache-path Co-authored-by: Björn Brauer <[email protected]> * add remote reusable workflow to ActionCache * fixup --------- Co-authored-by: Björn Brauer <[email protected]> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent cd40f3f commit f7a846d

10 files changed

+211
-16
lines changed

cmd/input.go

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type Input struct {
5757
actionCachePath string
5858
logPrefixJobID bool
5959
networkName string
60+
useNewActionCache bool
6061
}
6162

6263
func (i *Input) resolve(path string) string {

cmd/root.go

+6
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ func Execute(ctx context.Context, version string) {
9898
rootCmd.PersistentFlags().Uint16VarP(&input.cacheServerPort, "cache-server-port", "", 0, "Defines the port where the artifact server listens. 0 means a randomly available port.")
9999
rootCmd.PersistentFlags().StringVarP(&input.actionCachePath, "action-cache-path", "", filepath.Join(CacheHomeDir, "act"), "Defines the path where the actions get cached and host workspaces created.")
100100
rootCmd.PersistentFlags().StringVarP(&input.networkName, "network", "", "host", "Sets a docker network name. Defaults to host.")
101+
rootCmd.PersistentFlags().BoolVarP(&input.useNewActionCache, "use-new-action-cache", "", false, "Enable using the new Action Cache for storing Actions locally")
101102
rootCmd.SetArgs(args())
102103

103104
if err := rootCmd.Execute(); err != nil {
@@ -617,6 +618,11 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str
617618
Matrix: matrixes,
618619
ContainerNetworkMode: docker_container.NetworkMode(input.networkName),
619620
}
621+
if input.useNewActionCache {
622+
config.ActionCache = &runner.GoGitActionCache{
623+
Path: config.ActionCacheDir,
624+
}
625+
}
620626
r, err := runner.New(config)
621627
if err != nil {
622628
return err

pkg/container/docker_run.go

+19-1
Original file line numberDiff line numberDiff line change
@@ -671,10 +671,28 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
671671
}
672672

673673
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
674-
err := cr.cli.CopyToContainer(ctx, cr.id, destPath, tarStream, types.CopyToContainerOptions{})
674+
// Mkdir
675+
buf := &bytes.Buffer{}
676+
tw := tar.NewWriter(buf)
677+
_ = tw.WriteHeader(&tar.Header{
678+
Name: destPath,
679+
Mode: 777,
680+
Typeflag: tar.TypeDir,
681+
})
682+
tw.Close()
683+
err := cr.cli.CopyToContainer(ctx, cr.id, "/", buf, types.CopyToContainerOptions{})
684+
if err != nil {
685+
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
686+
}
687+
// Copy Content
688+
err = cr.cli.CopyToContainer(ctx, cr.id, destPath, tarStream, types.CopyToContainerOptions{})
675689
if err != nil {
676690
return fmt.Errorf("failed to copy content to container: %w", err)
677691
}
692+
// If this fails, then folders have wrong permissions on non root container
693+
if cr.UID != 0 || cr.GID != 0 {
694+
_ = cr.Exec([]string{"chown", "-R", fmt.Sprintf("%d:%d", cr.UID, cr.GID), destPath}, nil, "0", "")(ctx)
695+
}
678696
return nil
679697
}
680698

pkg/model/planner.go

+40-6
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,10 @@ func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, e
148148
workflow.Name = wf.workflowDirEntry.Name()
149149
}
150150

151-
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
152-
for k := range workflow.Jobs {
153-
if ok := jobNameRegex.MatchString(k); !ok {
154-
_ = f.Close()
155-
return nil, fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
156-
}
151+
err = validateJobName(workflow)
152+
if err != nil {
153+
_ = f.Close()
154+
return nil, err
157155
}
158156

159157
wp.workflows = append(wp.workflows, workflow)
@@ -164,6 +162,42 @@ func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, e
164162
return wp, nil
165163
}
166164

165+
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
166+
wp := new(workflowPlanner)
167+
168+
log.Debugf("Reading workflow %s", name)
169+
workflow, err := ReadWorkflow(f)
170+
if err != nil {
171+
if err == io.EOF {
172+
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
173+
}
174+
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
175+
}
176+
workflow.File = name
177+
if workflow.Name == "" {
178+
workflow.Name = name
179+
}
180+
181+
err = validateJobName(workflow)
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
wp.workflows = append(wp.workflows, workflow)
187+
188+
return wp, nil
189+
}
190+
191+
func validateJobName(workflow *Workflow) error {
192+
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
193+
for k := range workflow.Jobs {
194+
if ok := jobNameRegex.MatchString(k); !ok {
195+
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
196+
}
197+
}
198+
return nil
199+
}
200+
167201
type workflowPlanner struct {
168202
workflows []*Workflow
169203
}

pkg/runner/action.go

+21-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir string, act
4444
reader, closer, err := readFile("action.yml")
4545
if os.IsNotExist(err) {
4646
reader, closer, err = readFile("action.yaml")
47-
if err != nil {
47+
if os.IsNotExist(err) {
4848
if _, closer, err2 := readFile("Dockerfile"); err2 == nil {
4949
closer.Close()
5050
action := &model.Action{
@@ -91,6 +91,8 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir string, act
9191
}
9292
}
9393
return nil, err
94+
} else if err != nil {
95+
return nil, err
9496
}
9597
} else if err != nil {
9698
return nil, err
@@ -110,6 +112,17 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir string
110112
if stepModel.Type() != model.StepTypeUsesActionRemote {
111113
return nil
112114
}
115+
116+
if rc.Config != nil && rc.Config.ActionCache != nil {
117+
raction := step.(*stepActionRemote)
118+
ta, err := rc.Config.ActionCache.GetTarArchive(ctx, raction.cacheDir, raction.resolvedSha, "")
119+
if err != nil {
120+
return err
121+
}
122+
defer ta.Close()
123+
return rc.JobContainer.CopyTarStream(ctx, containerActionDir, ta)
124+
}
125+
113126
if err := removeGitIgnore(ctx, actionDir); err != nil {
114127
return err
115128
}
@@ -265,6 +278,13 @@ func execAsDocker(ctx context.Context, step actionStep, actionName string, based
265278
return err
266279
}
267280
defer buildContext.Close()
281+
} else if rc.Config.ActionCache != nil {
282+
rstep := step.(*stepActionRemote)
283+
buildContext, err = rc.Config.ActionCache.GetTarArchive(ctx, rstep.cacheDir, rstep.resolvedSha, contextDir)
284+
if err != nil {
285+
return err
286+
}
287+
defer buildContext.Close()
268288
}
269289
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
270290
ContextDir: contextDir,

pkg/runner/reusable_workflow.go

+40
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package runner
22

33
import (
4+
"archive/tar"
45
"context"
56
"errors"
67
"fmt"
@@ -33,12 +34,51 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
3334
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
3435
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
3536

37+
if rc.Config.ActionCache != nil {
38+
return newActionCacheReusableWorkflowExecutor(rc, filename, remoteReusableWorkflow)
39+
}
40+
3641
return common.NewPipelineExecutor(
3742
newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir)),
3843
newReusableWorkflowExecutor(rc, workflowDir, fmt.Sprintf("./.github/workflows/%s", remoteReusableWorkflow.Filename)),
3944
)
4045
}
4146

47+
func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, remoteReusableWorkflow *remoteReusableWorkflow) common.Executor {
48+
return func(ctx context.Context) error {
49+
ghctx := rc.getGithubContext(ctx)
50+
remoteReusableWorkflow.URL = ghctx.ServerURL
51+
sha, err := rc.Config.ActionCache.Fetch(ctx, filename, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, ghctx.Token)
52+
if err != nil {
53+
return err
54+
}
55+
archive, err := rc.Config.ActionCache.GetTarArchive(ctx, filename, sha, fmt.Sprintf(".github/workflows/%s", remoteReusableWorkflow.Filename))
56+
if err != nil {
57+
return err
58+
}
59+
defer archive.Close()
60+
treader := tar.NewReader(archive)
61+
if _, err = treader.Next(); err != nil {
62+
return err
63+
}
64+
planner, err := model.NewSingleWorkflowPlanner(remoteReusableWorkflow.Filename, treader)
65+
if err != nil {
66+
return err
67+
}
68+
plan, err := planner.PlanEvent("workflow_call")
69+
if err != nil {
70+
return err
71+
}
72+
73+
runner, err := NewReusableWorkflowRunner(rc)
74+
if err != nil {
75+
return err
76+
}
77+
78+
return runner.NewPlanExecutor(plan)(ctx)
79+
}
80+
}
81+
4282
var (
4383
executorLock sync.Mutex
4484
)

pkg/runner/runner.go

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type Config struct {
5959
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
6060
Matrix map[string]map[string]bool // Matrix config to run
6161
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
62+
ActionCache ActionCache // Use a custom ActionCache Implementation
6263
}
6364

6465
type caller struct {

pkg/runner/step.go

+13
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const (
3434
stepStagePost
3535
)
3636

37+
// Controls how many symlinks are resolved for local and remote Actions
38+
const maxSymlinkDepth = 10
39+
3740
func (s stepStage) String() string {
3841
switch s {
3942
case stepStagePre:
@@ -307,3 +310,13 @@ func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]st
307310
}
308311
}
309312
}
313+
314+
func symlinkJoin(filename, sym, parent string) (string, error) {
315+
dir := path.Dir(filename)
316+
dest := path.Join(dir, sym)
317+
prefix := path.Clean(parent) + "/"
318+
if strings.HasPrefix(dest, prefix) || prefix == "./" {
319+
return dest, nil
320+
}
321+
return "", fmt.Errorf("symlink tries to access file '%s' outside of '%s'", strings.ReplaceAll(dest, "'", "''"), strings.ReplaceAll(parent, "'", "''"))
322+
}

pkg/runner/step_action_local.go

+27-8
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package runner
33
import (
44
"archive/tar"
55
"context"
6+
"errors"
7+
"fmt"
68
"io"
9+
"io/fs"
710
"os"
811
"path"
912
"path/filepath"
@@ -42,15 +45,31 @@ func (sal *stepActionLocal) main() common.Executor {
4245
localReader := func(ctx context.Context) actionYamlReader {
4346
_, cpath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext)
4447
return func(filename string) (io.Reader, io.Closer, error) {
45-
tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, path.Join(cpath, filename))
46-
if err != nil {
47-
return nil, nil, os.ErrNotExist
48+
spath := path.Join(cpath, filename)
49+
for i := 0; i < maxSymlinkDepth; i++ {
50+
tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, spath)
51+
if errors.Is(err, fs.ErrNotExist) {
52+
return nil, nil, err
53+
} else if err != nil {
54+
return nil, nil, fs.ErrNotExist
55+
}
56+
treader := tar.NewReader(tars)
57+
header, err := treader.Next()
58+
if errors.Is(err, io.EOF) {
59+
return nil, nil, os.ErrNotExist
60+
} else if err != nil {
61+
return nil, nil, err
62+
}
63+
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
64+
spath, err = symlinkJoin(spath, header.Linkname, cpath)
65+
if err != nil {
66+
return nil, nil, err
67+
}
68+
} else {
69+
return treader, tars, nil
70+
}
4871
}
49-
treader := tar.NewReader(tars)
50-
if _, err := treader.Next(); err != nil {
51-
return nil, nil, os.ErrNotExist
52-
}
53-
return treader, tars, nil
72+
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
5473
}
5574
}
5675

pkg/runner/step_action_remote.go

+43
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package runner
22

33
import (
4+
"archive/tar"
45
"context"
56
"errors"
67
"fmt"
@@ -28,6 +29,8 @@ type stepActionRemote struct {
2829
action *model.Action
2930
env map[string]string
3031
remoteAction *remoteAction
32+
cacheDir string
33+
resolvedSha string
3134
}
3235

3336
var (
@@ -60,6 +63,46 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
6063
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
6164
}
6265
}
66+
if sar.RunContext.Config.ActionCache != nil {
67+
cache := sar.RunContext.Config.ActionCache
68+
69+
var err error
70+
sar.cacheDir = fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo)
71+
sar.resolvedSha, err = cache.Fetch(ctx, sar.cacheDir, sar.remoteAction.URL+"/"+sar.cacheDir, sar.remoteAction.Ref, github.Token)
72+
if err != nil {
73+
return err
74+
}
75+
76+
remoteReader := func(ctx context.Context) actionYamlReader {
77+
return func(filename string) (io.Reader, io.Closer, error) {
78+
spath := filename
79+
for i := 0; i < maxSymlinkDepth; i++ {
80+
tars, err := cache.GetTarArchive(ctx, sar.cacheDir, sar.resolvedSha, spath)
81+
if err != nil {
82+
return nil, nil, os.ErrNotExist
83+
}
84+
treader := tar.NewReader(tars)
85+
header, err := treader.Next()
86+
if err != nil {
87+
return nil, nil, os.ErrNotExist
88+
}
89+
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
90+
spath, err = symlinkJoin(spath, header.Linkname, ".")
91+
if err != nil {
92+
return nil, nil, err
93+
}
94+
} else {
95+
return treader, tars, nil
96+
}
97+
}
98+
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
99+
}
100+
}
101+
102+
actionModel, err := sar.readAction(ctx, sar.Step, sar.resolvedSha, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
103+
sar.action = actionModel
104+
return err
105+
}
63106

64107
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses))
65108
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{

0 commit comments

Comments
 (0)