1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-08-16 18:01:34 +00:00
forgejo-runner/act/container/docker_run_test.go
Earl Warren 96891ab314
feat: wait for services to be healthy before starting a job (#805)
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>
2025-08-07 04:36:26 +00:00

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")
})
}