mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 14:35:32 +00:00
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.
1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys
Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
1022 lines
41 KiB
Go
1022 lines
41 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package container
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
|
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/moby/moby/api/pkg/stdcopy"
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/api/types/mount"
|
|
"github.com/moby/moby/api/types/network"
|
|
mobyclient "github.com/moby/moby/client"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerInspect", mock.Anything, "amd64-job", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Env: []string{
|
|
"PATH=/opt/node/x64/bin:/usr/bin:/bin", "CONFLICT_VAR=container-value", "SOME_RANDOM_VAR=",
|
|
"ANOTHER_ONE=old-value", "ANOTHER_ONE=BUT_I_HAVE_VALUE", "LITERAL='quoted'=$HOME\nsecond line",
|
|
}}}}, nil)
|
|
env := map[string]string{
|
|
"PATH": "/custom/bin",
|
|
"RANDOM_VAR": "WITH_VALUE",
|
|
"ANOTHER_VAR": "",
|
|
"CONFLICT_VAR": "step-value",
|
|
}
|
|
require.NoError(t, (&containerReference{cli: client, id: "amd64-job"}).UpdateFromImageEnv(&env)(t.Context()))
|
|
assert.Equal(t, map[string]string{
|
|
"PATH": "/custom/bin:/opt/node/x64/bin:/usr/bin:/bin",
|
|
"RANDOM_VAR": "WITH_VALUE",
|
|
"ANOTHER_VAR": "",
|
|
"SOME_RANDOM_VAR": "",
|
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
|
"CONFLICT_VAR": "step-value",
|
|
"LITERAL": "'quoted'=$HOME\nsecond line",
|
|
}, env)
|
|
}
|
|
|
|
type mockDockerClient struct {
|
|
mobyclient.APIClient
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
|
|
args := m.Called(ctx, opts)
|
|
return args.Get(0).(mobyclient.ServerVersionResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
|
args := m.Called(ctx, execID, opts)
|
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
|
args := m.Called(ctx, containerID, opts)
|
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerLogs(ctx context.Context, containerID string, opts mobyclient.ContainerLogsOptions) (mobyclient.ContainerLogsResult, error) {
|
|
args := m.Called(ctx, containerID, opts)
|
|
return args.Get(0).(mobyclient.ContainerLogsResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerStart(ctx context.Context, containerID string, opts mobyclient.ContainerStartOptions) (mobyclient.ContainerStartResult, error) {
|
|
args := m.Called(ctx, containerID, opts)
|
|
return args.Get(0).(mobyclient.ContainerStartResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
|
args := m.Called(ctx, containerID, opts)
|
|
return args.Get(0).(mobyclient.ContainerWaitResult)
|
|
}
|
|
|
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
|
args := m.Called(ctx, id, options)
|
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
|
args := m.Called(ctx, opts)
|
|
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
|
args := m.Called(ctx, opts)
|
|
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) NetworkCreate(ctx context.Context, name string, opts mobyclient.NetworkCreateOptions) (mobyclient.NetworkCreateResult, error) {
|
|
args := m.Called(ctx, name, opts)
|
|
return args.Get(0).(mobyclient.NetworkCreateResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
|
}
|
|
|
|
type interruptReader struct {
|
|
started chan struct{}
|
|
interrupted chan struct{}
|
|
stopped chan struct{}
|
|
}
|
|
|
|
func (r *interruptReader) Read(_ []byte) (int, error) {
|
|
close(r.started)
|
|
<-r.interrupted
|
|
close(r.stopped)
|
|
return 0, io.EOF
|
|
}
|
|
|
|
type mockConn struct {
|
|
net.Conn
|
|
mock.Mock
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func (m *mockConn) Write(b []byte) (n int, err error) {
|
|
args := m.Called(b)
|
|
return args.Int(0), args.Error(1)
|
|
}
|
|
|
|
func (m *mockConn) Close() (err error) {
|
|
m.closed.Store(true)
|
|
return nil
|
|
}
|
|
|
|
func TestDockerExecAbort(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
|
conn := &mockConn{}
|
|
conn.On("Write", []byte{3}).
|
|
Run(func(mock.Arguments) { close(reader.interrupted) }).
|
|
Return(1, nil)
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
|
Conn: conn,
|
|
Reader: bufio.NewReader(reader),
|
|
}, nil)
|
|
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{},
|
|
}
|
|
|
|
channel := make(chan error)
|
|
|
|
go func() {
|
|
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
|
}()
|
|
|
|
<-reader.started
|
|
cancel()
|
|
|
|
err := <-channel
|
|
<-reader.stopped
|
|
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerExecFailure(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
|
Conn: &mockConn{},
|
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
|
}, nil)
|
|
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
|
ExitCode: 1,
|
|
}, nil)
|
|
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{},
|
|
}
|
|
|
|
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
|
var exitErr ExitCodeError
|
|
require.ErrorAs(t, err, &exitErr)
|
|
assert.Equal(t, ExitCodeError(1), exitErr)
|
|
assert.Equal(t, "Process completed with exit code 1.", err.Error())
|
|
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerExecStreamFailure(t *testing.T) {
|
|
ctx := t.Context()
|
|
client := &mockDockerClient{}
|
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).
|
|
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).
|
|
Return(mobyclient.ExecAttachResult{
|
|
Conn: &mockConn{},
|
|
Reader: bufio.NewReader(bytes.NewReader(stdcopyFrame(stdcopy.Systemerr, "unable to start exec"))),
|
|
}, nil)
|
|
require.ErrorContains(t, (&containerReference{id: "123", cli: client, input: &NewContainerInput{}}).
|
|
exec([]string{"sh"}, nil, "", "")(ctx), "unable to start exec")
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
|
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
|
// length) followed by the payload.
|
|
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
|
b := make([]byte, 8+len(payload))
|
|
b[0] = byte(stream)
|
|
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
|
copy(b[8:], payload)
|
|
return b
|
|
}
|
|
|
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
|
for _, testcase := range []struct {
|
|
name string
|
|
tty bool
|
|
}{
|
|
{name: "multiplexed"},
|
|
{name: "tty from container options", tty: true},
|
|
} {
|
|
t.Run(testcase.name, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
output := []byte("line one\nlast line without newline")
|
|
if !testcase.tty {
|
|
output = stdcopyFrame(stdcopy.Stdout, string(output))
|
|
}
|
|
var lines []string
|
|
logWriter := common.NewLineWriter(func(line string) bool {
|
|
lines = append(lines, line)
|
|
return true
|
|
})
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerInspect", ctx, "123", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Tty: testcase.tty}}}, nil)
|
|
conn := &mockConn{}
|
|
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
|
Return(mobyclient.ContainerAttachResult{
|
|
Conn: conn,
|
|
Reader: bufio.NewReader(bytes.NewReader(output)),
|
|
}, nil)
|
|
client.On("ContainerLogs", ctx, "123", mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}).
|
|
Return(io.NopCloser(bytes.NewReader(output)), nil)
|
|
statusCh := make(chan container.WaitResponse, 1)
|
|
statusCh <- container.WaitResponse{StatusCode: 0}
|
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
|
Return(mobyclient.ContainerWaitResult{Result: statusCh})
|
|
cr := &containerReference{id: "123", cli: client, input: &NewContainerInput{Stdout: logWriter, Stderr: logWriter}}
|
|
|
|
require.NoError(t, cr.attach()(ctx))
|
|
require.NoError(t, cr.wait()(ctx))
|
|
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
|
assert.True(t, conn.closed.Load())
|
|
require.NoError(t, cr.DumpLogs(ctx))
|
|
assert.Equal(t, []string{"line one\n", "last line without newline", "line one\n", "last line without newline"}, lines)
|
|
client.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
|
|
conn, peer := net.Pipe()
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
t.Cleanup(func() { _ = peer.Close() })
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerInspect", mock.Anything, "123", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{}}}, nil)
|
|
client.On("ContainerAttach", mock.Anything, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
|
Return(mobyclient.ContainerAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
|
|
client.On("ContainerStart", mock.Anything, "123", mobyclient.ContainerStartOptions{}).
|
|
Return(mobyclient.ContainerStartResult{}, errors.New("container start failed"))
|
|
cr := &containerReference{id: "123", cli: client, input: &NewContainerInput{}}
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
require.ErrorContains(t, cr.Start(true)(ctx), "container start failed")
|
|
cancel()
|
|
<-cr.attachDone
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerWaitFailure(t *testing.T) {
|
|
ctx := t.Context()
|
|
statusCh := make(chan container.WaitResponse, 1)
|
|
statusCh <- container.WaitResponse{StatusCode: 2}
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
|
Return(mobyclient.ContainerWaitResult{Result: statusCh})
|
|
cr := &containerReference{id: "123", cli: client}
|
|
|
|
err := cr.wait()(ctx)
|
|
var exitErr ExitCodeError
|
|
require.ErrorAs(t, err, &exitErr)
|
|
assert.Equal(t, ExitCodeError(2), exitErr)
|
|
assert.Equal(t, "Process completed with exit code 2.", err.Error())
|
|
statusCh <- container.WaitResponse{Error: &container.WaitExitError{Message: "container wait failed"}}
|
|
require.EqualError(t, cr.wait()(ctx), "failed to wait for container: container wait failed")
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
|
// be logged as one.
|
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|
removeFailure := errors.New("driver failed to remove root filesystem")
|
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
wantWait bool
|
|
waitErr error
|
|
wantErr error
|
|
}{
|
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
|
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
|
|
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
|
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
|
{name: "removed cleanly", err: nil},
|
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
logger, hook := logtest.NewLogger()
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
|
if tc.wantWait {
|
|
removed := make(chan container.WaitResponse, 1)
|
|
waitErrors := make(chan error, 1)
|
|
if tc.waitErr == nil {
|
|
removed <- container.WaitResponse{}
|
|
} else {
|
|
waitErrors <- tc.waitErr
|
|
}
|
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
|
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
|
}
|
|
cr := &containerReference{id: "abc", cli: client}
|
|
|
|
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
|
if tc.wantErr != nil {
|
|
assert.Equal(t, "abc", cr.id)
|
|
} else {
|
|
assert.Empty(t, cr.id)
|
|
}
|
|
assert.Empty(t, hook.AllRecords())
|
|
client.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A container whose id was never learned, because find() could not reach the daemon or
|
|
// create() lost its reply, must still be removed rather than leaking with its network. It
|
|
// was never started here, so it is not worth a kill of its own.
|
|
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
|
ctx := t.Context()
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
|
Return(mobyclient.ContainerRemoveResult{}, nil)
|
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
|
|
require.NoError(t, cr.remove()(ctx))
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestRemovePreservesClientCreationError(t *testing.T) {
|
|
t.Setenv("DOCKER_HOST", "://")
|
|
|
|
require.ErrorContains(t, (&containerReference{input: &NewContainerInput{Name: "job-1"}}).
|
|
Remove()(t.Context()), "failed to connect to docker daemon")
|
|
}
|
|
|
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
|
ctx := t.Context()
|
|
inspectOpts := mobyclient.ContainerInspectOptions{}
|
|
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
|
boom := errors.New("daemon unreachable")
|
|
for _, tc := range []struct {
|
|
name, cachedID string
|
|
cachedErr error
|
|
lookup container.InspectResponse
|
|
lookupErr error
|
|
listed []container.Summary
|
|
wantID string
|
|
retryList bool
|
|
}{
|
|
{name: "stale id cleared, name lookup empty", cachedID: "stale", cachedErr: notFound, lookupErr: notFound},
|
|
{name: "stale id cleared, name lookup repopulates", cachedID: "stale", cachedErr: notFound, lookup: container.InspectResponse{ID: "fresh", Name: "/job-1"}, wantID: "fresh"},
|
|
{name: "id match falls back to listing", lookup: container.InspectResponse{ID: "job-1abc", Name: "/somebody-else"}, listed: []container.Summary{{ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh"},
|
|
{name: "live id kept", cachedID: "live", wantID: "live"},
|
|
{name: "transient inspect error trusts cache", cachedID: "live", cachedErr: boom, wantID: "live"},
|
|
{name: "name lookup error falls back to listing", lookupErr: boom, listed: []container.Summary{{ID: "other", Names: []string{"/somebody-else"}}, {ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh", retryList: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := &mockDockerClient{}
|
|
cr := &containerReference{id: tc.cachedID, cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
if tc.cachedID != "" {
|
|
client.On("ContainerInspect", ctx, tc.cachedID, inspectOpts).Return(mobyclient.ContainerInspectResult{}, tc.cachedErr)
|
|
}
|
|
if tc.cachedID == "" || cerrdefs.IsNotFound(tc.cachedErr) {
|
|
client.On("ContainerInspect", ctx, "job-1", inspectOpts).Return(mobyclient.ContainerInspectResult{Container: tc.lookup}, tc.lookupErr)
|
|
}
|
|
if tc.listed != nil {
|
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{Items: tc.listed}, nil).Once()
|
|
}
|
|
require.NoError(t, cr.find()(ctx))
|
|
assert.Equal(t, tc.wantID, cr.id)
|
|
if tc.retryList {
|
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, boom)
|
|
cr.id = ""
|
|
require.ErrorIs(t, cr.find()(ctx), boom)
|
|
}
|
|
client.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunnerArchCachedPerRun(t *testing.T) {
|
|
var infos atomic.Int32
|
|
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.Header().Set("API-Version", "1.47")
|
|
if strings.HasSuffix(request.URL.Path, "/info") {
|
|
infos.Add(1)
|
|
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
|
|
}
|
|
}))
|
|
t.Cleanup(daemon.Close)
|
|
t.Setenv("DOCKER_HOST", daemon.URL)
|
|
extensions := &LinuxContainerEnvironmentExtensions{}
|
|
run := WithRunnerArch(t.Context())
|
|
var lookups sync.WaitGroup
|
|
for range 4 {
|
|
lookups.Go(func() { assert.Equal(t, "ARM64", extensions.GetRunnerContext(run)["arch"]) })
|
|
}
|
|
lookups.Wait()
|
|
assert.Equal(t, "ARM64", extensions.GetRunnerContext(t.Context())["arch"])
|
|
assert.EqualValues(t, 2, infos.Load())
|
|
}
|
|
|
|
// Every daemon entry point fails fast with a clear, container-named
|
|
// error when no live cr.id is known.
|
|
func TestRejectsMissingContainer(t *testing.T) {
|
|
ctx := t.Context()
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerInspect", ctx, "job-1", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
|
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
check := func(op string, err error) {
|
|
t.Helper()
|
|
require.ErrorIs(t, err, ErrContainerNotFound, op)
|
|
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, false)(ctx))
|
|
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
|
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
|
check("GetContainerArchive", err)
|
|
_, err = cr.Inspect(ctx)
|
|
check("Inspect", err)
|
|
|
|
// a known id the daemon has since dropped
|
|
client.On("ContainerInspect", ctx, "gone", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
|
|
removed := &containerReference{id: "gone", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
_, err = removed.Inspect(ctx)
|
|
check("Inspect after removal", err)
|
|
}
|
|
|
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|
for _, testcase := range []struct {
|
|
name, destination, extractionRoot, entry string
|
|
directory bool
|
|
}{
|
|
{name: "files", destination: "/var/run/act", extractionRoot: "/var/run/act", entry: "x"},
|
|
{name: "action below act volume", directory: true, destination: "/var/run/act/actions/checkout/", extractionRoot: "/var/run/act", entry: "actions/checkout/x"},
|
|
{name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
|
|
} {
|
|
t.Run(testcase.name, func(t *testing.T) {
|
|
hook := logtest.NewWithLevel(logging.LevelError)
|
|
ctx := common.WithLogger(t.Context(), logging.New(hook))
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
|
client.On("ContainerInspect", ctx, "job-1", mobyclient.ContainerInspectOptions{}).
|
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{ID: "fresh", Name: "/job-1"}}, nil)
|
|
client.On("CopyToContainer", ctx, "fresh", mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
opts := args.Get(2).(mobyclient.CopyToContainerOptions)
|
|
assert.Equal(t, testcase.extractionRoot, opts.DestinationPath)
|
|
header, err := tar.NewReader(opts.Content).Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, testcase.entry, header.Name)
|
|
}).Return(mobyclient.CopyToContainerResult{}, nil).Once()
|
|
|
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
if testcase.directory {
|
|
source := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
|
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
|
} else {
|
|
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
|
}
|
|
assert.Equal(t, "fresh", cr.id)
|
|
assert.Empty(t, hook.AllRecords())
|
|
client.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckVolumes(t *testing.T) {
|
|
testCases := []struct {
|
|
desc string
|
|
validVolumes []string
|
|
binds []string
|
|
expectedBinds []string
|
|
mounts []mount.Mount
|
|
expectedMounts []mount.Mount
|
|
}{
|
|
{
|
|
desc: "match all volumes",
|
|
validVolumes: []string{"**"},
|
|
binds: []string{
|
|
"shared_volume:/shared_volume",
|
|
"/home/test/data:/test_data",
|
|
"/etc/conf.d/base.json:/config/base.json",
|
|
"sql_data:/sql_data",
|
|
"/secrets/keys:/keys",
|
|
},
|
|
expectedBinds: []string{
|
|
"shared_volume:/shared_volume",
|
|
"/home/test/data:/test_data",
|
|
"/etc/conf.d/base.json:/config/base.json",
|
|
"sql_data:/sql_data",
|
|
"/secrets/keys:/keys",
|
|
},
|
|
},
|
|
{
|
|
desc: "no volumes can be matched",
|
|
validVolumes: []string{},
|
|
binds: []string{
|
|
"shared_volume:/shared_volume",
|
|
"/home/test/data:/test_data",
|
|
"/etc/conf.d/base.json:/config/base.json",
|
|
"sql_data:/sql_data",
|
|
"/secrets/keys:/keys",
|
|
},
|
|
expectedBinds: []string{},
|
|
},
|
|
{
|
|
desc: "only allowed volumes can be matched",
|
|
validVolumes: []string{
|
|
"shared_volume",
|
|
"/home/test/data",
|
|
"/etc/conf.d/*.json",
|
|
"/opt/**",
|
|
},
|
|
binds: []string{
|
|
"shared_volume:/shared_volume",
|
|
"/home/test/data:/test_data",
|
|
"/etc/conf.d/base.json:/config/base.json",
|
|
"/etc/conf.d/sub/nested.json:/config/nested.json",
|
|
"/opt/app/cache:/cache",
|
|
"sql_data:/sql_data",
|
|
"/secrets/keys:/keys",
|
|
},
|
|
expectedBinds: []string{
|
|
"shared_volume:/shared_volume",
|
|
"/home/test/data:/test_data",
|
|
"/etc/conf.d/base.json:/config/base.json",
|
|
"/opt/app/cache:/cache",
|
|
},
|
|
},
|
|
{
|
|
desc: "read-only rules allow only read-only mounts and copy nothing into volumes",
|
|
validVolumes: []string{"/etc/ssl:ro", "data:ro", "shared"},
|
|
binds: []string{"/etc/ssl:/ssl:ro", "/etc/ssl:/ssl", "data:/data:ro", "data:/data", "shared:/shared:ro"},
|
|
expectedBinds: []string{"/etc/ssl:/ssl:ro", "data:/data:ro,nocopy", "shared:/shared:ro"},
|
|
mounts: []mount.Mount{
|
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true, BindOptions: &mount.BindOptions{ReadOnlyNonRecursive: true}},
|
|
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true},
|
|
},
|
|
expectedMounts: []mount.Mount{
|
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
|
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true, VolumeOptions: &mount.VolumeOptions{NoCopy: true}},
|
|
},
|
|
},
|
|
{
|
|
desc: "read-only wildcard keeps writable rules writable",
|
|
validVolumes: []string{"**:ro", "shared"},
|
|
binds: []string{"/etc/ssl:/ssl:ro", "data:/data", "shared:/shared"},
|
|
expectedBinds: []string{"/etc/ssl:/ssl:ro", "shared:/shared"},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
logger, hook := logtest.NewLogger()
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
ValidVolumes: tc.validVolumes,
|
|
},
|
|
}
|
|
hostConf := &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts}
|
|
cr.sanitizeConfig(ctx, hostConf)
|
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
|
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
|
assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts))
|
|
})
|
|
}
|
|
}
|
|
|
|
// A volume driver decides for itself what it mounts, e.g. the local driver with device= binds
|
|
// any host path, which valid_volumes never gets to see.
|
|
func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
|
|
const escape = "--mount type=volume,src=job-escape,dst=/host,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=/"
|
|
|
|
hostConfig, _ := mergeOptions(t, "", escape+" --mount type=volume,src=job-plain,dst=/cache", false)
|
|
require.Len(t, hostConfig.Mounts, 1)
|
|
assert.Equal(t, "job-plain", hostConfig.Mounts[0].Source)
|
|
|
|
// the same mount from the runner's own options is the administrator's to make
|
|
hostConfig, _ = mergeOptions(t, escape, "", false)
|
|
require.Len(t, hostConfig.Mounts, 1)
|
|
assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source)
|
|
}
|
|
|
|
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
|
hostFile := filepath.Join(t.TempDir(), "host.env")
|
|
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
|
|
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
|
|
|
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
|
logger := logging.New(logtest.New())
|
|
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
|
|
|
require.ErrorContains(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}), "not allowed in a workflow")
|
|
|
|
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
|
require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}))
|
|
}
|
|
|
|
logger := logging.New(logtest.New())
|
|
cr := &containerReference{input: &NewContainerInput{
|
|
NetworkMode: "bridge",
|
|
RunnerOptions: "--env RUNNER_SECRET",
|
|
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
|
}}
|
|
|
|
config := &container.Config{}
|
|
require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), config, &container.HostConfig{}))
|
|
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
|
}
|
|
|
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
|
logger := logging.New(logtest.New())
|
|
|
|
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
|
hostConfig := &container.HostConfig{
|
|
PidMode: "host",
|
|
IpcMode: "host",
|
|
UTSMode: "host",
|
|
CgroupnsMode: "host",
|
|
UsernsMode: "host",
|
|
CapAdd: []string{"ALL"},
|
|
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
|
VolumesFrom: []string{"other"},
|
|
Runtime: "runc",
|
|
Isolation: "process",
|
|
VolumeDriver: "rogue",
|
|
MaskedPaths: []string{},
|
|
ReadonlyPaths: []string{},
|
|
CgroupParent: "/custom",
|
|
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
|
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
|
LogConfig: container.LogConfig{Type: "syslog", Config: map[string]string{"syslog-address": "tcp://169.254.169.254:514"}},
|
|
OomScoreAdj: -1000,
|
|
OomKillDisable: new(true),
|
|
}
|
|
|
|
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
|
|
|
assert.Equal(t, &container.HostConfig{}, hostConfig)
|
|
}
|
|
|
|
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) {
|
|
t.Helper()
|
|
logger, hook := logtest.NewLogger()
|
|
cr := &containerReference{input: &NewContainerInput{
|
|
RunnerOptions: runnerOptions,
|
|
WorkflowOptions: workflowOptions,
|
|
NetworkMode: "bridge",
|
|
UsernsMode: "private",
|
|
ValidVolumes: []string{"**"},
|
|
}}
|
|
|
|
hostConfig := &container.HostConfig{
|
|
Privileged: privileged,
|
|
UsernsMode: container.UsernsMode("private"),
|
|
NetworkMode: container.NetworkMode("bridge"),
|
|
}
|
|
require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, hostConfig))
|
|
return hostConfig, hook
|
|
}
|
|
|
|
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
|
// OS-independent options only, --device and --gpus need a linux/windows server OS
|
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
|
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
|
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1 " +
|
|
"--log-driver=syslog --log-opt syslog-address=tcp://169.254.169.254:514 " +
|
|
"--oom-score-adj=-1000 --oom-kill-disable"
|
|
|
|
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
|
// own options alone describe it, field for field
|
|
for _, runnerOptions := range []string{"--shm-size 1g", dangerousOptions, "--cap-add SYS_ADMIN --security-opt seccomp=unconfined"} {
|
|
runnerOnly, _ := mergeOptions(t, runnerOptions, "", false)
|
|
withWorkflow, _ := mergeOptions(t, runnerOptions, dangerousOptions, false)
|
|
|
|
assert.Equal(t, runnerOnly, withWorkflow, "runner options: %q", runnerOptions)
|
|
}
|
|
|
|
// the same options from the runner reach the daemon, even --userns, which no workflow may set
|
|
kept, _ := mergeOptions(t, dangerousOptions, "", false)
|
|
assert.Equal(t, "host", string(kept.PidMode))
|
|
assert.Equal(t, []string{"ALL"}, kept.CapAdd)
|
|
assert.Equal(t, "runc", kept.Runtime)
|
|
assert.Equal(t, "host", string(kept.UsernsMode))
|
|
assert.False(t, kept.Privileged)
|
|
|
|
// privileged is the administrator opting in, so the workflow's options are honored
|
|
privileged, _ := mergeOptions(t, "", dangerousOptions, true)
|
|
assert.Equal(t, "host", string(privileged.PidMode))
|
|
assert.Equal(t, []string{"ALL"}, privileged.CapAdd)
|
|
assert.Equal(t, []string{"seccomp=unconfined", "apparmor=unconfined"}, privileged.SecurityOpt)
|
|
}
|
|
|
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|
logger := logging.New(logtest.New())
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
|
|
base := t.TempDir()
|
|
allowed := filepath.Join(base, "allowed")
|
|
denied := filepath.Join(base, "denied")
|
|
require.NoError(t, os.MkdirAll(allowed, 0o700))
|
|
require.NoError(t, os.MkdirAll(denied, 0o700))
|
|
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
ValidVolumes: []string{filepath.Join(allowed, "**")},
|
|
},
|
|
}
|
|
|
|
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
|
|
hostConf := &container.HostConfig{
|
|
Binds: []string{escapingPath + ":/mnt"},
|
|
}
|
|
cr.sanitizeConfig(ctx, hostConf)
|
|
assert.Empty(t, hostConf.Binds)
|
|
|
|
linkPath := filepath.Join(allowed, "link")
|
|
if err := os.Symlink(denied, linkPath); err != nil {
|
|
t.Skipf("cannot create symlink: %v", err)
|
|
}
|
|
hostConf = &container.HostConfig{
|
|
Binds: []string{linkPath + ":/mnt"},
|
|
}
|
|
cr.sanitizeConfig(ctx, hostConf)
|
|
assert.Empty(t, hostConf.Binds)
|
|
|
|
hostConf = &container.HostConfig{
|
|
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
|
|
}
|
|
cr.sanitizeConfig(ctx, hostConf)
|
|
assert.Empty(t, hostConf.Binds)
|
|
}
|
|
|
|
func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
|
|
hostConfig, _ := mergeOptions(t, "", "--mount type=tmpfs,dst=/var/lib/mysql", false)
|
|
require.Len(t, hostConfig.Mounts, 1)
|
|
|
|
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
|
logger, hook := logtest.NewLogger()
|
|
sanitized := &container.HostConfig{Mounts: []mount.Mount{
|
|
hostConfig.Mounts[0],
|
|
{Type: mount.TypeVolume, Target: "/anonymous"},
|
|
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
|
}}
|
|
(&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), sanitized)
|
|
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
|
assert.Len(t, hook.AllRecords(), 2)
|
|
}
|
|
}
|
|
|
|
func TestContainerInfoFromInspect(t *testing.T) {
|
|
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
|
info := containerInfoFromInspect(container.InspectResponse{
|
|
ID: "abc123",
|
|
State: &container.State{Status: "running", Running: true},
|
|
})
|
|
|
|
assert.Equal(t, "abc123", info.ID)
|
|
assert.Equal(t, "running", info.State)
|
|
assert.Equal(t, HealthNone, info.Health)
|
|
assert.Empty(t, info.Ports)
|
|
})
|
|
|
|
t.Run("reports the health status and the last probe output", func(t *testing.T) {
|
|
info := containerInfoFromInspect(container.InspectResponse{
|
|
State: &container.State{
|
|
Status: "running",
|
|
Health: &container.Health{
|
|
Status: container.Unhealthy,
|
|
Log: []*container.HealthcheckResult{
|
|
{Output: "first\n"},
|
|
{Output: "connection refused\n"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
assert.Equal(t, HealthUnhealthy, info.Health)
|
|
assert.Equal(t, "connection refused", info.HealthOutput)
|
|
})
|
|
|
|
t.Run("reports the published ports", func(t *testing.T) {
|
|
info := containerInfoFromInspect(container.InspectResponse{
|
|
State: &container.State{Status: "running"},
|
|
NetworkSettings: &container.NetworkSettings{
|
|
Ports: network.PortMap{
|
|
network.MustParsePort("5432/tcp"): []network.PortBinding{{HostPort: "49153"}},
|
|
network.MustParsePort("6379/tcp"): nil,
|
|
},
|
|
},
|
|
})
|
|
|
|
assert.Equal(t, map[string]string{"5432": "49153"}, info.Ports)
|
|
})
|
|
|
|
t.Run("tolerates a container without state", func(t *testing.T) {
|
|
info := containerInfoFromInspect(container.InspectResponse{ID: "abc123"})
|
|
|
|
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"},
|
|
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
|
|
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
|
|
},
|
|
HostConfig: &container.HostConfig{
|
|
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
|
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
|
|
},
|
|
})
|
|
|
|
assert.Equal(t, map[string]string{
|
|
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
|
"/workspace/owner/repo/tmp": "",
|
|
"/var/run/docker.sock": "/var/run/docker.sock",
|
|
"/cache": "/var/lib/docker/volumes/cache/_data/project",
|
|
"/etc/resolv.conf": "/custom/resolv.conf",
|
|
"/etc/hosts": "",
|
|
"/etc/hostname": "",
|
|
}, info.Mounts)
|
|
})
|
|
}
|
|
|
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|
logger := logging.New(logtest.New())
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
NetworkMode: "bridge",
|
|
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
|
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
|
|
ValidVolumes: []string{"/host/tools"},
|
|
},
|
|
}
|
|
|
|
hostConf := &container.HostConfig{
|
|
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
|
}
|
|
require.NoError(t, cr.mergeContainerConfigs(ctx, &container.Config{}, hostConf))
|
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
|
assert.Empty(t, hostConf.Mounts)
|
|
}
|
|
|
|
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|
warnings := func(runnerOptions, workflowOptions string) int {
|
|
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
|
return len(hook.AllRecords())
|
|
}
|
|
|
|
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
|
assert.Zero(t, warnings("", "--shm-size 1g"))
|
|
assert.Equal(t, 1, warnings("--network host", ""))
|
|
assert.Equal(t, 1, warnings("", "--privileged"))
|
|
}
|
|
|
|
func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
|
for _, entrypoint := range [][]string{nil, {"sleep", "3600"}} {
|
|
for _, optionEntrypoint := range []string{"/sbin/dumb-init", ""} {
|
|
logger, hook := logtest.NewLogger()
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
NetworkMode: "bridge",
|
|
Entrypoint: entrypoint,
|
|
RunnerOptions: "--entrypoint=runner-entrypoint",
|
|
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
|
},
|
|
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
|
if len(entrypoint) > 0 {
|
|
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
|
} else {
|
|
assert.Equal(t, []string{optionEntrypoint}, opts.Config.Entrypoint)
|
|
}
|
|
require.NotNil(t, opts.HostConfig.Init)
|
|
assert.True(t, *opts.HostConfig.Init)
|
|
return mobyclient.ContainerCreateResult{ID: "created"}, nil
|
|
}},
|
|
}
|
|
require.NoError(t, cr.create(nil, nil)(common.WithLogger(t.Context(), logger)))
|
|
assert.Equal(t, "created", cr.id)
|
|
assert.Equal(t, len(entrypoint) > 0, len(hook.AllRecords()) == 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
|
logger := logging.New(logtest.New())
|
|
cr := &containerReference{input: &NewContainerInput{
|
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
|
}}
|
|
|
|
require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}))
|
|
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
|
|
}
|
|
|
|
// A dead daemon must fail the job, not panic through logrus and not silently
|
|
// drop the requested platform.
|
|
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
|
cli := &mockDockerClient{}
|
|
cli.On("ServerVersion", mock.Anything, mock.Anything).
|
|
Return(mobyclient.ServerVersionResult{}, errors.New("cannot connect to the Docker daemon"))
|
|
|
|
supported, err := supportsContainerImagePlatform(t.Context(), cli)
|
|
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
|
|
assert.False(t, supported)
|
|
}
|