mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
feat: wait for healthy services and fill the job context (#1107)
Service containers were started and then left alone, so a job's first step could run while a database was still starting up. The runner now waits for every service whose image or `options` declare a healthcheck, as GitHub does. An unhealthy service fails the job with its container log, one that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait), and one that exits without a healthcheck only gets its log and a warning.
The started containers also fill the `job` context, whose fields existed but were never populated: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`. `ports` is keyed by the plain container port, so `job.services.postgres.ports['5432']` resolves to the host port Docker picked.
---------
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1107
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -78,3 +78,14 @@ func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string
|
||||
}
|
||||
return args.Get(0).(io.ReadCloser), err
|
||||
}
|
||||
|
||||
func (cm *containerMock) DumpLogs(ctx context.Context) error {
|
||||
return cm.Called(ctx).Error(0)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
||||
args := cm.Called(ctx)
|
||||
info, _ := args.Get(0).(*container.Info)
|
||||
err, _ := args.Get(1).(error)
|
||||
return info, err
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/opencontainers/selinux/go-selinux"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// RunContext contains info about current job
|
||||
@@ -56,7 +57,7 @@ type RunContext struct {
|
||||
IntraActionState map[string]map[string]string
|
||||
ExprEval ExpressionEvaluator
|
||||
JobContainer container.ExecutionsEnvironment
|
||||
ServiceContainers []container.ExecutionsEnvironment
|
||||
serviceContainers []*serviceContainer
|
||||
OutputMappings map[MappableOutput]MappableOutput
|
||||
JobName string
|
||||
ActionPath string
|
||||
@@ -84,6 +85,9 @@ type RunContext struct {
|
||||
// failures. Those failures must still make success() false and failure() true for later
|
||||
// main-step if evaluation.
|
||||
jobFailed bool
|
||||
// empty for a host-mode job, which starts no container
|
||||
jobContainerID string
|
||||
jobNetworkName string
|
||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||
@@ -92,6 +96,15 @@ type RunContext struct {
|
||||
unsecureCommandMu sync.Mutex
|
||||
}
|
||||
|
||||
// serviceContainer pairs a service container with the workflow id that keys job.services.
|
||||
type serviceContainer struct {
|
||||
name string
|
||||
image string
|
||||
container container.ExecutionsEnvironment
|
||||
logsDumped bool
|
||||
info *container.Info // last poll, the source of the `job.services` entry
|
||||
}
|
||||
|
||||
// setCurrentStepEnv records the environment of the step about to run.
|
||||
func (rc *RunContext) setCurrentStepEnv(env map[string]string) {
|
||||
rc.unsecureCommandMu.Lock()
|
||||
@@ -508,7 +521,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Privileged: rc.Config.Privileged,
|
||||
UsernsMode: rc.Config.UsernsMode,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
|
||||
NetworkMode: networkName,
|
||||
NetworkAliases: []string{serviceID},
|
||||
@@ -516,7 +529,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
PortBindings: portBindings,
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
|
||||
}
|
||||
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||
@@ -551,6 +564,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return errors.New("Failed to create job container")
|
||||
}
|
||||
|
||||
rc.jobNetworkName = networkName
|
||||
|
||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
||||
return common.NewPipelineExecutor(
|
||||
rc.pullServicesImages(rc.Config.ForcePull),
|
||||
@@ -558,9 +573,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.stopJobContainer(),
|
||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||
IfBool(createAndDeleteNetwork),
|
||||
rc.startServiceContainers(networkName),
|
||||
rc.startServiceContainers(),
|
||||
rc.reportUnstartedServices(),
|
||||
rc.waitForServiceContainers(),
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
rc.captureJobContainerInfo(),
|
||||
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
|
||||
Name: "workflow/event.json",
|
||||
Mode: 0o644,
|
||||
@@ -585,7 +603,7 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
if removeJobContainer {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
}
|
||||
if len(rc.ServiceContainers) > 0 {
|
||||
if len(rc.serviceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
@@ -682,21 +700,21 @@ func (rc *RunContext) stopJobContainer() common.Executor {
|
||||
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
for _, c := range rc.ServiceContainers {
|
||||
execs = append(execs, c.Pull(forcePull))
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, svc.container.Pull(forcePull))
|
||||
}
|
||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) startServiceContainers(_ string) common.Executor {
|
||||
func (rc *RunContext) startServiceContainers() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
for _, c := range rc.ServiceContainers {
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, common.NewPipelineExecutor(
|
||||
c.Pull(false),
|
||||
c.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
c.Start(false),
|
||||
svc.container.Pull(false),
|
||||
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
svc.container.Start(false),
|
||||
))
|
||||
}
|
||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||
@@ -706,13 +724,159 @@ func (rc *RunContext) startServiceContainers(_ string) common.Executor {
|
||||
func (rc *RunContext) stopServiceContainers() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
for _, c := range rc.ServiceContainers {
|
||||
execs = append(execs, c.Remove().Finally(c.Close()))
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
|
||||
}
|
||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServiceReadyTimeout = 5 * time.Minute
|
||||
serviceReadyPollMax = 32 * time.Second
|
||||
)
|
||||
|
||||
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait
|
||||
|
||||
// reportUnstartedServices logs a service that did not start. The steps that need it
|
||||
// report it better than the runner can, so the job carries on.
|
||||
func (rc *RunContext) reportUnstartedServices() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
for _, svc := range rc.serviceContainers {
|
||||
info, err := svc.inspect(ctx)
|
||||
if err != nil {
|
||||
logger.Debugf("unable to inspect service '%s': %v", svc.name, err)
|
||||
continue
|
||||
}
|
||||
if info.State == container.StateRunning {
|
||||
continue
|
||||
}
|
||||
svc.dumpLogs(ctx)
|
||||
logger.Warnf("Docker container %s is not in running state: %s (%d)", info.ID, info.State, info.ExitCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitForServiceContainers blocks until every service that declares a healthcheck reports
|
||||
// healthy, as GitHub does, so a first step cannot connect before the service listens.
|
||||
func (rc *RunContext) waitForServiceContainers() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if len(rc.serviceContainers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeout := rc.Config.ServiceReadyTimeout
|
||||
switch {
|
||||
case timeout < 0:
|
||||
// disabled, but still describe the containers for `job.services`
|
||||
for _, svc := range rc.serviceContainers {
|
||||
if _, err := svc.inspect(ctx); err != nil && !errors.Is(err, container.ErrContainerNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case timeout == 0:
|
||||
timeout = defaultServiceReadyTimeout
|
||||
}
|
||||
|
||||
// the first error cancels the rest, so a failure does not wait out a sibling's timeout
|
||||
group, groupCtx := errgroup.WithContext(ctx)
|
||||
for _, svc := range rc.serviceContainers {
|
||||
group.Go(func() error {
|
||||
return svc.waitUntilHealthy(groupCtx, timeout)
|
||||
})
|
||||
}
|
||||
return group.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// waitUntilHealthy waits on the healthcheck alone, so a container that declares none is
|
||||
// ready at once and one that exited is left to the steps that need it.
|
||||
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
interval := serviceReadyPollInterval
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
info, err := svc.inspect(ctx)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil { // the wait ended, an inspect error only noticed it
|
||||
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("the service '%s' did not become healthy within %s%s", svc.name, timeout, svc.healthOutputSuffix())
|
||||
}
|
||||
return ctxErr
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, container.ErrContainerNotFound):
|
||||
return nil // gone, so there is no health left to wait on
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case info.Health == container.HealthUnhealthy:
|
||||
svc.dumpLogs(ctx)
|
||||
common.Logger(ctx).Errorf("Failed to initialize container %s", svc.image)
|
||||
return fmt.Errorf("the service '%s' is unhealthy%s", svc.name, svc.healthOutputSuffix())
|
||||
case info.Health != container.HealthStarting:
|
||||
rawLogger.Infof("%s service is healthy.", svc.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds()))
|
||||
select {
|
||||
case <-ctx.Done(): // reported at the top of the loop
|
||||
case <-time.After(interval):
|
||||
}
|
||||
interval = min(interval*2, serviceReadyPollMax)
|
||||
}
|
||||
}
|
||||
|
||||
// dumpLogs writes the container's log to the job log once, however often it is reported.
|
||||
func (svc *serviceContainer) dumpLogs(ctx context.Context) {
|
||||
if svc.logsDumped {
|
||||
return
|
||||
}
|
||||
svc.logsDumped = true
|
||||
if err := svc.container.DumpLogs(ctx); err != nil {
|
||||
common.Logger(ctx).Debugf("unable to read the log of service '%s': %v", svc.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// inspect also records the state for the `job.services` context.
|
||||
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
|
||||
info, err := svc.container.Inspect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to inspect service '%s': %w", svc.name, err)
|
||||
}
|
||||
svc.info = info
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (svc *serviceContainer) healthOutputSuffix() string {
|
||||
if svc.info == nil || svc.info.HealthOutput == "" {
|
||||
return ""
|
||||
}
|
||||
return ": " + svc.info.HealthOutput
|
||||
}
|
||||
|
||||
// captureJobContainerInfo is a convenience: failing to describe the container must not
|
||||
// fail the job.
|
||||
func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
info, err := rc.JobContainer.Inspect(ctx)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("unable to inspect the job container: %v", err)
|
||||
return nil
|
||||
}
|
||||
rc.jobContainerID = info.ID
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the mounts and binds for the worker
|
||||
|
||||
// ActionCacheDir is for rc
|
||||
@@ -1053,9 +1217,26 @@ func (rc *RunContext) getJobContext() *model.JobContext {
|
||||
if rc.jobCancelled {
|
||||
jobStatus = "cancelled"
|
||||
}
|
||||
return &model.JobContext{
|
||||
Status: jobStatus,
|
||||
|
||||
jobContext := &model.JobContext{
|
||||
Status: jobStatus,
|
||||
Services: map[string]model.JobService{}, // an empty map, never null
|
||||
}
|
||||
if rc.jobContainerID != "" {
|
||||
jobContext.Container.ID = rc.jobContainerID
|
||||
jobContext.Container.Network = rc.jobNetworkName
|
||||
}
|
||||
for _, svc := range rc.serviceContainers {
|
||||
if svc.info == nil {
|
||||
continue
|
||||
}
|
||||
jobContext.Services[svc.name] = model.JobService{
|
||||
ID: svc.info.ID,
|
||||
Network: rc.jobNetworkName,
|
||||
Ports: svc.info.Ports,
|
||||
}
|
||||
}
|
||||
return jobContext
|
||||
}
|
||||
|
||||
func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
require "github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -225,6 +227,12 @@ func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor {
|
||||
return func(context.Context) error { return nil }
|
||||
}
|
||||
|
||||
func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
|
||||
return &container.Info{ID: "fake", State: "running", Health: container.HealthNone}, nil
|
||||
}
|
||||
|
||||
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
||||
|
||||
// Regression test: a service without a `credentials:` block resolves to empty
|
||||
// credentials, which used to overwrite the job container's own credentials.
|
||||
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
||||
@@ -563,7 +571,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
}
|
||||
|
||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
||||
@@ -586,7 +594,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: jobContainer,
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -1045,6 +1053,199 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWaitForServiceContainers(t *testing.T) {
|
||||
origInterval := serviceReadyPollInterval
|
||||
serviceReadyPollInterval = time.Millisecond
|
||||
defer func() { serviceReadyPollInterval = origInterval }()
|
||||
|
||||
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
|
||||
return &RunContext{
|
||||
Config: &Config{ServiceReadyTimeout: timeout},
|
||||
serviceContainers: services,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("returns as soon as a service without a healthcheck runs", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "redis", container: service})
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
service.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("waits while a service is still starting", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
service.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).Return(&container.Info{
|
||||
State: "running",
|
||||
Health: container.HealthUnhealthy,
|
||||
HealthOutput: "connection refused",
|
||||
}, nil).Once()
|
||||
service.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused")
|
||||
service.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("lets the steps run when a service exits without a healthcheck", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("proceeds when the container is gone", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return((*container.Info)(nil), container.ErrContainerNotFound).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("fails right away when one service fails while another is still starting", func(t *testing.T) {
|
||||
failing := &containerMock{}
|
||||
failing.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{State: "running", Health: container.HealthUnhealthy}, nil)
|
||||
failing.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||
starting := &containerMock{}
|
||||
starting.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||
|
||||
rc := newRunContext(10*time.Second,
|
||||
&serviceContainer{name: "failing", container: failing},
|
||||
&serviceContainer{name: "starting", container: starting})
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- rc.waitForServiceContainers()(context.Background()) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "the service 'failing' is unhealthy")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("waitForServiceContainers did not fail fast; it waited for the starting service")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gives up once the timeout expires", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||
|
||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||
})
|
||||
|
||||
t.Run("gives up with the same message when the deadline stops an inspect", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).
|
||||
Run(func(args mock.Arguments) { <-args.Get(0).(context.Context).Done() }).
|
||||
Return((*container.Info)(nil), errors.New("inspect aborted"))
|
||||
|
||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||
})
|
||||
|
||||
t.Run("does not wait when the timeout is negative", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
// Still described once, so the `job.services` context is filled either way.
|
||||
service.On("Inspect", mock.Anything).
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Once()
|
||||
|
||||
svc := &serviceContainer{name: "postgres", container: service}
|
||||
rc := newRunContext(-1, svc)
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
service.AssertExpectations(t)
|
||||
assert.Equal(t, "id", svc.info.ID)
|
||||
})
|
||||
|
||||
t.Run("fails on an inspect error even when the timeout is negative", func(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once()
|
||||
|
||||
rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to inspect service 'postgres'")
|
||||
})
|
||||
|
||||
t.Run("is a no-op without services", func(t *testing.T) {
|
||||
require.NoError(t, newRunContext(0).waitForServiceContainers()(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReportUnstartedServices(t *testing.T) {
|
||||
dead := &containerMock{}
|
||||
dead.On("Inspect", mock.Anything).Return(&container.Info{ID: "dead-id", State: "exited", ExitCode: 1}, nil).Once()
|
||||
dead.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||
running := &containerMock{}
|
||||
running.On("Inspect", mock.Anything).Return(&container.Info{ID: "run-id", State: "running"}, nil).Once()
|
||||
|
||||
rc := &RunContext{serviceContainers: []*serviceContainer{
|
||||
{name: "postgres", container: dead},
|
||||
{name: "redis", container: running},
|
||||
}}
|
||||
|
||||
require.NoError(t, rc.reportUnstartedServices()(context.Background()))
|
||||
dead.AssertExpectations(t)
|
||||
running.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetJobContextReportsContainers(t *testing.T) {
|
||||
rc := &RunContext{
|
||||
jobNetworkName: "job-network",
|
||||
jobContainerID: "job-container-id",
|
||||
serviceContainers: []*serviceContainer{
|
||||
{name: "postgres", info: &container.Info{ID: "svc-id", Ports: map[string]string{"5432": "49153"}}},
|
||||
// A service that publishes no port reports an empty map, as GitHub does.
|
||||
{name: "redis", info: &container.Info{ID: "redis-id", Ports: map[string]string{}}},
|
||||
// A service that never reported is left out rather than reported as empty.
|
||||
{name: "mailhog"},
|
||||
},
|
||||
}
|
||||
|
||||
jobContext := rc.getJobContext()
|
||||
|
||||
assert.Equal(t, "job-container-id", jobContext.Container.ID)
|
||||
assert.Equal(t, "job-network", jobContext.Container.Network)
|
||||
assert.Equal(t, map[string]model.JobService{
|
||||
"postgres": {ID: "svc-id", Network: "job-network", Ports: map[string]string{"5432": "49153"}},
|
||||
"redis": {ID: "redis-id", Network: "job-network", Ports: map[string]string{}},
|
||||
}, jobContext.Services)
|
||||
}
|
||||
|
||||
// A job that never started a container reports an empty context, not a placeholder.
|
||||
func TestGetJobContextWithoutContainer(t *testing.T) {
|
||||
jobContext := (&RunContext{}).getJobContext()
|
||||
|
||||
assert.Empty(t, jobContext.Container.ID)
|
||||
assert.Empty(t, jobContext.Container.Network)
|
||||
assert.Empty(t, jobContext.Services)
|
||||
}
|
||||
|
||||
func TestImageOSFromImage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
image string
|
||||
|
||||
@@ -94,6 +94,7 @@ type Config struct {
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||
|
||||
6
act/runner/testdata/services/push.yaml
vendored
6
act/runner/testdata/services/push.yaml
vendored
@@ -15,3 +15,9 @@ jobs:
|
||||
echo "id: ${{ job.services.postgres.id }}"
|
||||
echo "network: ${{ job.services.postgres.network }}"
|
||||
echo "ports: ${{ job.services.postgres.ports }}"
|
||||
- name: The job context describes the started containers
|
||||
run: |
|
||||
test -n "${{ job.container.id }}"
|
||||
test -n "${{ job.services.postgres.id }}"
|
||||
test -n "${{ job.services.postgres.ports['80'] }}"
|
||||
test "${{ job.services.postgres.network }}" = "${{ job.container.network }}"
|
||||
|
||||
Reference in New Issue
Block a user