mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2025-08-16 18:01:34 +00:00
If a --health-cmd is defined for a container, block until its status is healthy or unhealthy. The timeout is defined by the server internal logic based on associated --health-* defined delays. If it blocks indefinitely, the job timeout will eventually cancel it. While waiting, the simplest solution would be to sleep 1 second until the container is healthy or unhealthy. To minimize log verbosity, the sleep interval is instead set to --health-interval and default to one second if it is not defined. This logic does not apply to host containers as they do not support services. They are assumed to always be healthy. If --health-cmd is set for the container running a job, the first step will start to run without waiting for the container to become healthy. There may be valid use cases for that but they are not the focus of this implementation. <!--start release-notes-assistant--> <!--URL:https://code.forgejo.org/forgejo/runner--> - features - [PR](https://code.forgejo.org/forgejo/runner/pulls/805): <!--number 805 --><!--line 0 --><!--description ZmVhdDogd2FpdCBmb3Igc2VydmljZXMgdG8gYmUgaGVhbHRoeSBiZWZvcmUgc3RhcnRpbmcgYSBqb2I=-->feat: wait for services to be healthy before starting a job<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/805 Co-authored-by: Earl Warren <contact@earl-warren.org> Co-committed-by: Earl Warren <contact@earl-warren.org>
464 lines
13 KiB
Go
464 lines
13 KiB
Go
package container
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.forgejo.org/forgejo/runner/v9/act/common"
|
|
|
|
"github.com/docker/docker/api/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/client"
|
|
"github.com/sirupsen/logrus/hooks/test"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDocker(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
ctx := context.Background()
|
|
client, err := GetDockerClient(ctx)
|
|
assert.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
|
ContextDir: "testdata",
|
|
ImageTag: "envmergetest",
|
|
})
|
|
|
|
err = dockerBuild(ctx)
|
|
assert.NoError(t, err)
|
|
|
|
cr := &containerReference{
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "envmergetest",
|
|
},
|
|
}
|
|
env := map[string]string{
|
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
|
"RANDOM_VAR": "WITH_VALUE",
|
|
"ANOTHER_VAR": "",
|
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
|
}
|
|
|
|
envExecutor := cr.extractFromImageEnv(&env)
|
|
err = envExecutor(ctx)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, map[string]string{
|
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
|
"RANDOM_VAR": "WITH_VALUE",
|
|
"ANOTHER_VAR": "",
|
|
"SOME_RANDOM_VAR": "",
|
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
|
}, env)
|
|
}
|
|
|
|
type mockDockerClient struct {
|
|
client.APIClient
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts container.ExecOptions) (container.ExecCreateResponse, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(container.ExecCreateResponse), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts container.ExecAttachOptions) (types.HijackedResponse, error) {
|
|
args := m.Called(ctx, id, opts)
|
|
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) {
|
|
args := m.Called(ctx, execID)
|
|
return args.Get(0).(container.ExecInspect), args.Error(1)
|
|
}
|
|
|
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options container.CopyToContainerOptions) error {
|
|
args := m.Called(ctx, id, path, content, options)
|
|
return args.Error(0)
|
|
}
|
|
|
|
type endlessReader struct {
|
|
io.Reader
|
|
}
|
|
|
|
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
|
return 1, nil
|
|
}
|
|
|
|
type mockConn struct {
|
|
net.Conn
|
|
mock.Mock
|
|
}
|
|
|
|
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) {
|
|
return nil
|
|
}
|
|
|
|
func TestDockerExecAbort(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
conn := &mockConn{}
|
|
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("container.ExecOptions")).Return(container.ExecCreateResponse{ID: "id"}, nil)
|
|
// container.ExecStartOptions should be container.ExecAttachOptions but fails
|
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("container.ExecStartOptions")).Return(types.HijackedResponse{
|
|
Conn: conn,
|
|
Reader: bufio.NewReader(endlessReader{}),
|
|
}, nil)
|
|
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "image",
|
|
},
|
|
}
|
|
|
|
channel := make(chan error)
|
|
|
|
go func() {
|
|
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
|
}()
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
cancel()
|
|
|
|
err := <-channel
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerExecFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
conn := &mockConn{}
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("container.ExecOptions")).Return(container.ExecCreateResponse{ID: "id"}, nil)
|
|
// container.ExecStartOptions should be container.ExecAttachOptions but fails
|
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("container.ExecStartOptions")).Return(types.HijackedResponse{
|
|
Conn: conn,
|
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
|
}, nil)
|
|
client.On("ContainerExecInspect", ctx, "id").Return(container.ExecInspect{
|
|
ExitCode: 1,
|
|
}, nil)
|
|
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "image",
|
|
},
|
|
}
|
|
|
|
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
|
assert.Error(t, err, "exit with `FAILURE`: 1")
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerCopyTarStream(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
conn := &mockConn{}
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "image",
|
|
},
|
|
}
|
|
|
|
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
conn := &mockConn{}
|
|
|
|
merr := fmt.Errorf("Failure")
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "image",
|
|
},
|
|
}
|
|
|
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
assert.ErrorIs(t, err, merr)
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
conn := &mockConn{}
|
|
|
|
merr := fmt.Errorf("Failure")
|
|
|
|
client := &mockDockerClient{}
|
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(nil)
|
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("container.CopyToContainerOptions")).Return(merr)
|
|
cr := &containerReference{
|
|
id: "123",
|
|
cli: client,
|
|
input: &NewContainerInput{
|
|
Image: "image",
|
|
},
|
|
}
|
|
|
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
assert.ErrorIs(t, err, merr)
|
|
|
|
conn.AssertExpectations(t)
|
|
client.AssertExpectations(t)
|
|
}
|
|
|
|
// Type assert containerReference implements ExecutionsEnvironment
|
|
var _ ExecutionsEnvironment = &containerReference{}
|
|
|
|
func TestCheckVolumes(t *testing.T) {
|
|
testCases := []struct {
|
|
desc string
|
|
validVolumes []string
|
|
binds []string
|
|
expectedBinds []string
|
|
}{
|
|
{
|
|
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",
|
|
},
|
|
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",
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
logger, _ := test.NewNullLogger()
|
|
ctx := common.WithLogger(context.Background(), logger)
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
ValidVolumes: tc.validVolumes,
|
|
},
|
|
}
|
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeJobOptions(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
options string
|
|
config *container.Config
|
|
hostConfig *container.HostConfig
|
|
}{
|
|
{
|
|
name: "Ok",
|
|
options: `--volume /frob:/nitz --volume somevolume --tmpfs /tmp:exec,noatime --hostname alternatehost --health-cmd "healthz one" --health-interval 10s --health-timeout 5s --health-retries 3 --health-start-period 30s`,
|
|
config: &container.Config{
|
|
Volumes: map[string]struct{}{"somevolume": {}},
|
|
Hostname: "alternatehost",
|
|
Healthcheck: &container.HealthConfig{
|
|
Test: []string{"CMD-SHELL", "healthz one"},
|
|
Interval: 10 * time.Second,
|
|
Timeout: 5 * time.Second,
|
|
StartPeriod: 30 * time.Second,
|
|
Retries: 3,
|
|
},
|
|
},
|
|
hostConfig: &container.HostConfig{
|
|
Binds: []string{"/frob:/nitz"},
|
|
Tmpfs: map[string]string{"/tmp": "exec,noatime"},
|
|
},
|
|
},
|
|
{
|
|
name: "DisableHealthCheck",
|
|
options: `--no-healthcheck`,
|
|
config: &container.Config{
|
|
Healthcheck: &container.HealthConfig{
|
|
Test: []string{"NONE"},
|
|
},
|
|
},
|
|
hostConfig: &container.HostConfig{},
|
|
},
|
|
{
|
|
name: "Ignore",
|
|
options: "--pid=host --device=/dev/sda",
|
|
config: &container.Config{},
|
|
hostConfig: &container.HostConfig{},
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
cr := &containerReference{
|
|
input: &NewContainerInput{
|
|
JobOptions: testCase.options,
|
|
},
|
|
}
|
|
config, hostConfig, err := cr.mergeJobOptions(context.Background(), &container.Config{}, &container.HostConfig{})
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, testCase.config, config)
|
|
assert.EqualValues(t, testCase.hostConfig, hostConfig)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerRun_isHealthy(t *testing.T) {
|
|
cr := containerReference{
|
|
id: "containerid",
|
|
input: &NewContainerInput{
|
|
NetworkAliases: []string{"servicename"},
|
|
},
|
|
}
|
|
ctx := context.Background()
|
|
makeInspectResponse := func(interval time.Duration, status container.HealthStatus, test []string) container.InspectResponse {
|
|
return container.InspectResponse{
|
|
Config: &container.Config{
|
|
Image: "example.com/some/image",
|
|
Healthcheck: &container.HealthConfig{
|
|
Interval: interval,
|
|
Test: test,
|
|
},
|
|
},
|
|
ContainerJSONBase: &container.ContainerJSONBase{
|
|
State: &container.State{
|
|
Health: &container.Health{
|
|
Status: status,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
t.Run("IncompleteResponseOrNoHealthCheck", func(t *testing.T) {
|
|
wait, err := cr.isHealthy(ctx, container.InspectResponse{})
|
|
assert.Zero(t, wait)
|
|
assert.NoError(t, err)
|
|
|
|
// --no-healthcheck translates into a NONE test command
|
|
resp := makeInspectResponse(0, container.NoHealthcheck, []string{"NONE"})
|
|
wait, err = cr.isHealthy(ctx, resp)
|
|
assert.Zero(t, wait)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("StartingUndefinedIntervalIsNotZero", func(t *testing.T) {
|
|
resp := makeInspectResponse(0, container.Starting, nil)
|
|
wait, err := cr.isHealthy(ctx, resp)
|
|
assert.NotZero(t, wait)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("StartingWithInterval", func(t *testing.T) {
|
|
expectedWait := time.Duration(42)
|
|
resp := makeInspectResponse(expectedWait, container.Starting, nil)
|
|
actualWait, err := cr.isHealthy(ctx, resp)
|
|
assert.Equal(t, expectedWait, actualWait)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("Unhealthy", func(t *testing.T) {
|
|
resp := makeInspectResponse(0, container.Unhealthy, nil)
|
|
wait, err := cr.isHealthy(ctx, resp)
|
|
assert.Zero(t, wait)
|
|
assert.ErrorContains(t, err, "is not healthy")
|
|
})
|
|
|
|
t.Run("Healthy", func(t *testing.T) {
|
|
resp := makeInspectResponse(0, container.Healthy, nil)
|
|
wait, err := cr.isHealthy(ctx, resp)
|
|
assert.Zero(t, wait)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("UnknownStatus", func(t *testing.T) {
|
|
resp := makeInspectResponse(0, container.NoHealthcheck, nil)
|
|
wait, err := cr.isHealthy(ctx, resp)
|
|
assert.Zero(t, wait)
|
|
assert.ErrorContains(t, err, "unexpected")
|
|
})
|
|
}
|