mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-29 11:26:31 +08:00
`uses:` steps and job hooks kept their "Run …" group open until they finished. Gitea doesn't nest groups, so the action's own first `::group::` closed the header and everything before it stayed hidden there, e.g. all of goreleaser's output in https://gitea.com/gitea/cli/actions/runs/1121069/jobs/1519275. The header group now closes right after its details, as in actions/runner. The action header also matches actions/runner's otherwise now: it is titled by the `uses:` reference, lists the resolved inputs including `action.yml` defaults without empty ones, is shown for pre stages too, and post stages start with `Post job cleanup.`. --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1345 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
730 lines
23 KiB
Go
730 lines
23 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
|
"gitea.com/gitea/runner/act/container"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type closerFunc func()
|
|
|
|
func (close closerFunc) Close() error {
|
|
close()
|
|
return nil
|
|
}
|
|
|
|
func TestActionReader(t *testing.T) {
|
|
yaml := strings.ReplaceAll(`
|
|
name: 'name'
|
|
runs:
|
|
using: 'node16'
|
|
main: 'main.js'
|
|
`, "\t", " ")
|
|
yamlAction := &model.Action{
|
|
Name: "name",
|
|
Runs: model.ActionRuns{
|
|
Using: "node16",
|
|
Main: "main.js",
|
|
PreIf: "always()",
|
|
PostIf: "always()",
|
|
},
|
|
}
|
|
|
|
table := []struct {
|
|
name string
|
|
step *model.Step
|
|
filename string
|
|
fileContent string
|
|
expected *model.Action
|
|
}{
|
|
{
|
|
name: "readActionYml",
|
|
step: &model.Step{},
|
|
filename: "action.yml",
|
|
fileContent: yaml,
|
|
expected: yamlAction,
|
|
},
|
|
{
|
|
name: "readActionYaml",
|
|
step: &model.Step{},
|
|
filename: "action.yaml",
|
|
fileContent: yaml,
|
|
expected: yamlAction,
|
|
},
|
|
{
|
|
name: "readDockerfile",
|
|
step: &model.Step{},
|
|
filename: "Dockerfile",
|
|
fileContent: "FROM ubuntu:20.04",
|
|
expected: &model.Action{
|
|
Name: "(Synthetic)",
|
|
Runs: model.ActionRuns{
|
|
Using: "docker",
|
|
Image: "Dockerfile",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "readLowercaseDockerfile",
|
|
step: &model.Step{},
|
|
filename: "dockerfile",
|
|
fileContent: "FROM ubuntu:20.04",
|
|
expected: &model.Action{
|
|
Name: "(Synthetic)",
|
|
Runs: model.ActionRuns{
|
|
Using: "docker",
|
|
Image: "dockerfile",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "readWithArgs",
|
|
step: &model.Step{
|
|
With: map[string]string{
|
|
"args": "cmd",
|
|
},
|
|
},
|
|
expected: &model.Action{
|
|
Name: "(Synthetic)",
|
|
Inputs: map[string]model.Input{
|
|
"args": {},
|
|
"cwd": {
|
|
Description: "(Actual working directory)",
|
|
Required: false,
|
|
Default: "actionDir/actionPath",
|
|
},
|
|
"command": {
|
|
Description: "(Actual program)",
|
|
Required: false,
|
|
Default: "cmd",
|
|
},
|
|
},
|
|
Runs: model.ActionRuns{
|
|
Using: "node12",
|
|
Main: "trampoline.js",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range table {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
closed := false
|
|
|
|
readFile := func(filename string) (io.Reader, io.Closer, error) {
|
|
if tt.filename != filename {
|
|
return nil, nil, fs.ErrNotExist
|
|
}
|
|
|
|
return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil
|
|
}
|
|
|
|
writeFile := func(filename string, _data []byte, perm fs.FileMode) error {
|
|
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
|
|
assert.Equal(t, fs.FileMode(0o644), perm)
|
|
return nil
|
|
}
|
|
|
|
action, err := readActionImpl(t.Context(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.expected, action)
|
|
|
|
assert.Equal(t, tt.filename != "", closed)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionRunner(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
for _, tt := range []struct {
|
|
name string
|
|
inputs map[string]model.Input
|
|
with map[string]string
|
|
state map[string]string
|
|
expectedEnv map[string]string
|
|
warning string
|
|
}{
|
|
{
|
|
name: "with-input",
|
|
inputs: map[string]model.Input{"key": {Default: "default value"}},
|
|
expectedEnv: map[string]string{"INPUT_KEY": "default value"},
|
|
},
|
|
{
|
|
name: "declared input ignores case",
|
|
inputs: map[string]model.Input{"key": {Default: "default value"}},
|
|
with: map[string]string{"KEY": "provided value"},
|
|
expectedEnv: map[string]string{"INPUT_KEY": "provided value"},
|
|
},
|
|
{
|
|
name: "unexpected inputs warn once across stages",
|
|
inputs: map[string]model.Input{"key": {}},
|
|
with: map[string]string{"KEY": "provided value", "UNKNOWN": "value", "other%0A\r\n": "value"},
|
|
warning: "##[warning]Unexpected input(s) 'UNKNOWN', 'other%250A%0D%0A', valid inputs are ['key']",
|
|
},
|
|
{
|
|
name: "no declared inputs",
|
|
with: map[string]string{"unknown": "value"},
|
|
warning: "##[warning]Unexpected input(s) 'unknown', valid inputs are ['']",
|
|
},
|
|
{
|
|
name: "restore-saved-state",
|
|
state: map[string]string{"name": "state value"},
|
|
expectedEnv: map[string]string{"STATE_name": "state value"},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hook := logtest.NewWithLevel(logging.LevelWarn)
|
|
ctx := common.WithLogger(t.Context(), logging.New(hook))
|
|
cm := &containerMock{}
|
|
containerActionDir := "/var/run/act/actions/" + model.UsesHash("/org/repo/path@ref") + "/"
|
|
cm.On("CopyDir", containerActionDir, "dir/", false, true).Return(func(_ctx context.Context) error { return nil })
|
|
|
|
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
|
for k, v := range tt.expectedEnv {
|
|
if env[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(_ctx context.Context) error { return nil })
|
|
cm.On("Exec", nodeActionCommand(containerActionDir+"path/pre.js"), mock.Anything, "", "").Return(func(_ctx context.Context) error { return nil })
|
|
cm.On("Exec", nodeActionCommand(containerActionDir+"path/post.js"), envMatcher, "", "").Return(func(_ctx context.Context) error { return nil })
|
|
|
|
remoteAction := mustNewRemoteAction(t, "org/repo/path@ref")
|
|
step := &stepActionRemote{
|
|
Step: &model.Step{ID: "step", Uses: "org/repo/path@ref", With: tt.with},
|
|
RunContext: &RunContext{
|
|
Config: &Config{PresetGitHubContext: &model.GithubContext{}},
|
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
|
IntraActionState: map[string]map[string]string{"step": tt.state},
|
|
JobContainer: cm,
|
|
},
|
|
action: &model.Action{Inputs: tt.inputs, Runs: model.ActionRuns{Using: "node16", Pre: "pre.js", Post: "post.js"}},
|
|
actionDir: "dir",
|
|
env: (&model.Step{With: tt.with}).GetEnv(),
|
|
remoteAction: remoteAction,
|
|
}
|
|
require.NoError(t, runPreStep(step)(ctx))
|
|
assert.Empty(t, hook.AllRecords())
|
|
require.NoError(t, runActionImpl(step, "dir", remoteAction)(ctx))
|
|
require.NoError(t, runPostStep(step)(ctx))
|
|
if tt.warning == "" {
|
|
assert.Empty(t, hook.AllRecords())
|
|
} else {
|
|
require.Len(t, hook.AllRecords(), 1)
|
|
assert.Equal(t, tt.warning, hook.LastRecord().Message)
|
|
rawOutput, ok := hook.LastRecord().Attr(rawOutputField)
|
|
require.True(t, ok)
|
|
assert.Equal(t, true, rawOutput)
|
|
}
|
|
cm.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink creation requires privileges on Windows")
|
|
}
|
|
requireHostTools(t, "node")
|
|
|
|
actionDir := t.TempDir()
|
|
entrypoint := filepath.Join(actionDir, "index.mjs")
|
|
require.NoError(t, os.WriteFile(entrypoint, []byte(`
|
|
import {fileURLToPath} from "node:url";
|
|
const self = fileURLToPath(import.meta.url);
|
|
if (process.argv[1] !== self) {
|
|
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
|
|
process.exitCode = 1;
|
|
}
|
|
`), 0o600))
|
|
|
|
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
|
|
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
|
|
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
|
|
|
|
args := nodeActionCommand(symlinkedEntrypoint)
|
|
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
|
|
require.NoError(t, err, "%s", output)
|
|
}
|
|
|
|
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|
cm := &containerMock{}
|
|
|
|
var captured *container.NewContainerInput
|
|
origContainerNewContainer := ContainerNewContainer
|
|
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
captured = input
|
|
return cm
|
|
}
|
|
defer func() {
|
|
ContainerNewContainer = origContainerNewContainer
|
|
}()
|
|
|
|
ctx := t.Context()
|
|
rc := &RunContext{
|
|
Name: "job",
|
|
Config: &Config{
|
|
Secrets: map[string]string{
|
|
"DOCKER_USERNAME": "docker-user",
|
|
"DOCKER_PASSWORD": "docker-password",
|
|
},
|
|
},
|
|
Run: &model.Run{
|
|
JobID: "job",
|
|
Workflow: &model.Workflow{
|
|
Name: "test",
|
|
Jobs: map[string]*model.Job{
|
|
"job": {},
|
|
},
|
|
},
|
|
},
|
|
JobContainer: cm,
|
|
StepResults: map[string]*model.StepResult{},
|
|
}
|
|
env := map[string]string{}
|
|
step := &stepMock{}
|
|
step.On("getRunContext").Return(rc)
|
|
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
|
step.On("getEnv").Return(&env)
|
|
|
|
newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
|
|
|
assert.Empty(t, captured.Username)
|
|
assert.Empty(t, captured.Password)
|
|
assert.True(t, captured.AutoRemove)
|
|
step.AssertExpectations(t)
|
|
}
|
|
|
|
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
actionDir := t.TempDir()
|
|
|
|
releaseCopy := make(chan struct{})
|
|
release := sync.OnceFunc(func() { close(releaseCopy) })
|
|
defer release()
|
|
|
|
copyEntered := make(chan struct{})
|
|
|
|
cm := &containerMock{}
|
|
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
|
|
close(copyEntered)
|
|
<-releaseCopy
|
|
return nil
|
|
})
|
|
|
|
step := &stepActionRemote{
|
|
Step: &model.Step{Uses: "remote/action@v1"},
|
|
RunContext: &RunContext{
|
|
Config: &Config{},
|
|
JobContainer: cm,
|
|
},
|
|
}
|
|
|
|
copyDone := make(chan error, 1)
|
|
go func() {
|
|
copyDone <- maybeCopyToActionDir(ctx, step, actionDir, "", "/var/run/act/actions/")
|
|
}()
|
|
|
|
select {
|
|
case <-copyEntered:
|
|
case err := <-copyDone:
|
|
t.Fatalf("maybeCopyToActionDir returned before CopyDir was entered: %v", err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("CopyDir was not entered within 1 second")
|
|
}
|
|
|
|
peerAcquired := make(chan struct{})
|
|
go func() {
|
|
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
|
|
if !assert.NoError(t, err) {
|
|
return
|
|
}
|
|
close(peerAcquired)
|
|
unlock()
|
|
}()
|
|
|
|
select {
|
|
case <-peerAcquired:
|
|
t.Fatal("peer AcquireCloneLock returned while CopyDir was running")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
release()
|
|
|
|
select {
|
|
case err := <-copyDone:
|
|
if err != nil {
|
|
t.Fatalf("maybeCopyToActionDir returned error: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("maybeCopyToActionDir did not return after CopyDir was unblocked")
|
|
}
|
|
|
|
select {
|
|
case <-peerAcquired:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("peer AcquireCloneLock did not proceed after lock released")
|
|
}
|
|
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|
actionDir := t.TempDir()
|
|
|
|
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
|
|
require.NoError(t, err)
|
|
unlockOnce := sync.OnceFunc(unlock)
|
|
defer unlockOnce()
|
|
|
|
innerEntered := make(chan struct{})
|
|
releaseInner := make(chan struct{})
|
|
releaseOnce := sync.OnceFunc(func() { close(releaseInner) })
|
|
defer releaseOnce()
|
|
|
|
origImageExists := ContainerImageExistsLocally
|
|
ContainerImageExistsLocally = func(_ context.Context, _, _ string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
defer func() { ContainerImageExistsLocally = origImageExists }()
|
|
|
|
origBuildExec := ContainerNewDockerBuildExecutor
|
|
ContainerNewDockerBuildExecutor = func(_ container.NewDockerBuildExecutorInput) common.Executor {
|
|
return func(_ context.Context) error {
|
|
close(innerEntered)
|
|
<-releaseInner
|
|
return nil
|
|
}
|
|
}
|
|
defer func() { ContainerNewDockerBuildExecutor = origBuildExec }()
|
|
|
|
step := &stepActionRemote{
|
|
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
|
|
RunContext: &RunContext{
|
|
Config: &Config{Env: testCheckoutEnv()},
|
|
Run: &model.Run{
|
|
JobID: "1",
|
|
Workflow: &model.Workflow{
|
|
Name: "wf",
|
|
Jobs: map[string]*model.Job{"1": {}},
|
|
},
|
|
},
|
|
JobContainer: &containerMock{},
|
|
},
|
|
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
|
env: map[string]string{},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
|
|
|
|
select {
|
|
case <-innerEntered:
|
|
t.Fatal("inner build executor ran before clone lock was released")
|
|
case err := <-done:
|
|
t.Fatalf("execAsDocker returned before inner was entered: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
unlockOnce()
|
|
|
|
select {
|
|
case <-innerEntered:
|
|
case err := <-done:
|
|
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
|
}
|
|
|
|
cancel()
|
|
releaseOnce()
|
|
assert.ErrorIs(t, <-done, context.Canceled)
|
|
}
|
|
|
|
func TestExecAsDockerImageChecks(t *testing.T) {
|
|
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
|
|
t.Cleanup(func() {
|
|
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
|
|
})
|
|
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
|
|
|
|
for _, testcase := range []struct {
|
|
name, platform string
|
|
exists, matches, rebuild bool
|
|
wantBuild, wantRemove bool
|
|
wantChecks []string
|
|
}{
|
|
{name: "missing image", platform: "linux/arm64", wantBuild: true, wantChecks: []string{"any"}},
|
|
{name: "unspecified platform", exists: true, matches: true, wantChecks: []string{"any"}},
|
|
{name: "any platform", platform: "any", exists: true, matches: true, wantChecks: []string{"any"}},
|
|
{name: "matching platform", platform: "linux/arm64", exists: true, matches: true, wantChecks: []string{"any", "linux/arm64"}},
|
|
{name: "different platform", platform: "linux/arm64", exists: true, wantBuild: true, wantRemove: true, wantChecks: []string{"any", "linux/arm64"}},
|
|
{name: "forced rebuild", exists: true, matches: true, rebuild: true, wantBuild: true, wantChecks: []string{"any"}},
|
|
} {
|
|
t.Run(testcase.name, func(t *testing.T) {
|
|
var checks []string
|
|
ContainerImageExistsLocally = func(_ctx context.Context, _image, platform string) (bool, error) {
|
|
checks = append(checks, platform)
|
|
return testcase.exists && (platform == "any" || testcase.matches), nil
|
|
}
|
|
var built, removed bool
|
|
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
|
|
return func(context.Context) error { built = true; return nil }
|
|
}
|
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodDelete {
|
|
removed = true
|
|
_, _ = io.WriteString(writer, `[]`)
|
|
} else {
|
|
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
|
|
}
|
|
})
|
|
step := &stepActionRemote{
|
|
Step: &model.Step{ID: "action"},
|
|
RunContext: &RunContext{
|
|
Config: &Config{
|
|
PresetGitHubContext: &model.GithubContext{},
|
|
ContainerArchitecture: testcase.platform,
|
|
ForceRebuild: testcase.rebuild,
|
|
},
|
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
|
JobContainer: &jobContainerMock{},
|
|
},
|
|
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
|
env: map[string]string{},
|
|
}
|
|
dir := t.TempDir()
|
|
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
|
|
assert.Equal(t, testcase.wantChecks, checks)
|
|
assert.Equal(t, testcase.wantBuild, built)
|
|
assert.Equal(t, testcase.wantRemove, removed)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerActionImageTag(t *testing.T) {
|
|
assert.Equal(t,
|
|
"act-abc123-dockeraction:latest",
|
|
dockerActionImageTag("owner/repo", "abc123", false),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
|
|
dockerActionImageTag("owner/repo", "./", true),
|
|
)
|
|
assert.Equal(t,
|
|
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
|
|
dockerActionImageTag("owner/repo", "./sub", true),
|
|
)
|
|
|
|
collisions := [][2]struct {
|
|
repoName string
|
|
actionName string
|
|
}{
|
|
{{"a/b-c", "./"}, {"a-b/c", "./"}},
|
|
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
|
|
}
|
|
for _, c := range collisions {
|
|
assert.NotEqual(t,
|
|
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
|
|
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
|
|
"local docker action tags must differ for %q/%q vs %q/%q",
|
|
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
|
|
)
|
|
}
|
|
|
|
assert.NotEqual(t,
|
|
dockerActionImageTag("owner/repo", "./", true),
|
|
dockerActionImageTag("owner/repo", "./sub", true),
|
|
)
|
|
|
|
workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}
|
|
job := newTestRC(workflow, nil)
|
|
step := &stepActionRemote{Step: &model.Step{}, RunContext: job}
|
|
image := job.dockerActionImage(t.Context(), step, "abc123", false)
|
|
assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-"))
|
|
assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false))
|
|
assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false))
|
|
assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false))
|
|
job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)
|
|
assert.Len(t, job.dockerActionImages, 2)
|
|
}
|
|
|
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|
orig := ContainerNewContainer
|
|
defer func() { ContainerNewContainer = orig }()
|
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(writer, `{"Architecture":"x86_64"}`)
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
stage stepStage
|
|
with map[string]string
|
|
inputs map[string]model.Input
|
|
runs model.ActionRuns
|
|
env map[string]string
|
|
wantCmd []string
|
|
wantEntrypoint []string
|
|
warning string
|
|
}{
|
|
{
|
|
name: "main stage prefers manifest values",
|
|
stage: stepStageMain,
|
|
with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
|
|
runs: model.ActionRuns{
|
|
Entrypoint: "main.sh",
|
|
Args: []string{"manifest"},
|
|
Env: map[string]string{"ACTION_ONLY": "manifest"},
|
|
},
|
|
wantCmd: []string{"manifest"},
|
|
wantEntrypoint: []string{"main.sh"},
|
|
},
|
|
{
|
|
name: "main stage uses caller fallbacks in any case and warns about undeclared inputs",
|
|
stage: stepStageMain,
|
|
with: map[string]string{"ARGS": "caller --flag", "entryPoint": "input.sh --verbose", "unknown": "value"},
|
|
inputs: map[string]model.Input{"message": {}},
|
|
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
|
|
wantCmd: []string{"caller", "--flag"},
|
|
wantEntrypoint: []string{"input.sh", "--verbose"},
|
|
warning: "##[warning]Unexpected input(s) 'unknown', valid inputs are ['entryPoint', 'args', 'message']",
|
|
},
|
|
{
|
|
name: "manifest args keep caller input quotes literal",
|
|
stage: stepStageMain,
|
|
with: map[string]string{"args": "it's literal"},
|
|
runs: model.ActionRuns{Args: []string{"${{ inputs.args }}"}},
|
|
env: map[string]string{"INPUT_ARGS": "it's literal"},
|
|
wantCmd: []string{"it's literal"},
|
|
},
|
|
{
|
|
name: "caller input skips its default expression",
|
|
stage: stepStageMain,
|
|
with: map[string]string{"MESSAGE": "provided"},
|
|
inputs: map[string]model.Input{"message": {Default: "${{ fromJSON('') }}"}},
|
|
runs: model.ActionRuns{Args: []string{"${{ inputs.message }}"}},
|
|
env: map[string]string{"INPUT_MESSAGE": "provided"},
|
|
wantCmd: []string{"provided"},
|
|
},
|
|
{
|
|
name: "explicit empty manifest args suppress caller args",
|
|
stage: stepStageMain,
|
|
with: map[string]string{"args": "it's ignored"},
|
|
runs: model.ActionRuns{Args: []string{}},
|
|
wantCmd: []string{},
|
|
},
|
|
{
|
|
name: "pre stage keeps step environment",
|
|
stage: stepStagePre,
|
|
with: map[string]string{"entrypoint": "input.sh"},
|
|
runs: model.ActionRuns{
|
|
PreEntrypoint: "pre.sh --verbose",
|
|
Args: []string{"hello"},
|
|
Env: map[string]string{"SHARED": "manifest"},
|
|
},
|
|
env: map[string]string{"SHARED": "step"},
|
|
wantCmd: []string{"hello"},
|
|
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
|
},
|
|
{
|
|
name: "post stage uses manifest entrypoint",
|
|
stage: stepStagePost,
|
|
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
|
|
wantCmd: []string{"hello"},
|
|
wantEntrypoint: []string{"post.sh"},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
hook := logtest.NewWithLevel(logging.LevelWarn)
|
|
ctx := common.WithLogger(t.Context(), logging.New(hook))
|
|
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
|
|
cm := &containerMock{}
|
|
var input *container.NewContainerInput
|
|
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
input = in
|
|
return cm
|
|
}
|
|
|
|
step := &stepActionRemote{
|
|
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
|
|
RunContext: &RunContext{
|
|
Config: &Config{PresetGitHubContext: &model.GithubContext{}},
|
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
|
JobContainer: cm,
|
|
},
|
|
action: &model.Action{Inputs: tc.inputs, Runs: tc.runs},
|
|
env: mergeMaps(tc.env),
|
|
remoteAction: mustNewRemoteAction(t, "org/action@v1"),
|
|
}
|
|
|
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
|
cm.On("Remove").Return(func(context.Context) error { return nil })
|
|
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
|
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
|
|
|
if tc.stage == stepStageMain {
|
|
require.NoError(t, runActionImpl(step, t.TempDir(), step.remoteAction)(ctx))
|
|
} else {
|
|
require.NoError(t, execAsDocker(ctx, step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
|
}
|
|
if tc.warning == "" {
|
|
assert.Empty(t, hook.AllRecords())
|
|
} else {
|
|
require.Len(t, hook.AllRecords(), 1)
|
|
assert.Equal(t, tc.warning, hook.LastRecord().Message)
|
|
}
|
|
require.NotNil(t, input)
|
|
assert.Equal(t, tc.wantCmd, input.Cmd)
|
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
|
for key, value := range mergeMaps(tc.runs.Env, tc.env) {
|
|
assert.Contains(t, input.Env, key+"="+value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
|
newStep := func(runs model.ActionRuns) actionStep {
|
|
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
|
}
|
|
ctx := t.Context()
|
|
|
|
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
|
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
|
|
|
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
|
|
assert.True(t, hasPreStep(newStep(withStages))(ctx))
|
|
assert.True(t, hasPostStep(newStep(withStages))(ctx))
|
|
}
|