Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions cmd/nerdctl/compose/compose_up_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"testing"
"time"

"github.com/docker/go-connections/nat"
"gotest.tools/v3/assert"
Expand Down Expand Up @@ -1314,3 +1315,111 @@ services:

testCase.Run(t)
}

func TestComposeUpHealthcheck(t *testing.T) {
Comment thread
opjt marked this conversation as resolved.
testCase := nerdtest.Setup()

testCase.Setup = func(data test.Data, helpers test.Helpers) {
composeYAML := fmt.Sprintf(`
services:
web:
image: %s
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost"]
interval: 10s
timeout: 5s
retries: 3
start_period: 2s
`, testutil.NginxAlpineImage)

composePath := data.Temp().Save(composeYAML, "compose.yaml")
projectName := filepath.Base(filepath.Dir(composePath))
containerName := serviceparser.DefaultContainerName(projectName, "web", "1")

data.Labels().Set("composePath", composePath)
data.Labels().Set("containerName", containerName)

helpers.Ensure("compose", "-f", composePath, "up", "-d")
nerdtest.EnsureContainerStarted(helpers, containerName)
}

testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("container", "inspect", data.Labels().Get("containerName"))
}

testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeSuccess,
Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) {
assert.Equal(t, 1, len(dc), "unexpected number of containers")
hc := dc[0].Config.Healthcheck
assert.Assert(t, hc != nil, "healthcheck config should not be nil")
assert.Assert(t, len(hc.Test) >= 2, "healthcheck test should have at least 2 elements")
assert.Equal(t, "CMD-SHELL", hc.Test[0])
assert.Equal(t, "curl -f http://localhost", hc.Test[1])
assert.Equal(t, 10*time.Second, hc.Interval)
assert.Equal(t, 5*time.Second, hc.Timeout)
assert.Equal(t, 3, hc.Retries)
assert.Equal(t, 2*time.Second, hc.StartPeriod)
}),
}
}

testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
if data.Labels().Get("composePath") != "" {
helpers.Anyhow("compose", "-f", data.Labels().Get("composePath"), "down", "-v")
}
}

testCase.Run(t)
}

func TestComposeUpHealthcheckDisabled(t *testing.T) {
testCase := nerdtest.Setup()

testCase.Setup = func(data test.Data, helpers test.Helpers) {
composeYAML := fmt.Sprintf(`
services:
web:
image: %s
command: sleep infinity
healthcheck:
disable: true
`, testutil.CommonImage)

composePath := data.Temp().Save(composeYAML, "compose.yaml")
projectName := filepath.Base(filepath.Dir(composePath))
containerName := serviceparser.DefaultContainerName(projectName, "web", "1")

data.Labels().Set("composePath", composePath)
data.Labels().Set("containerName", containerName)

helpers.Ensure("compose", "-f", composePath, "up", "-d")
nerdtest.EnsureContainerStarted(helpers, containerName)
}

testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("container", "inspect", data.Labels().Get("containerName"))
}

testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeSuccess,
Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) {
assert.Equal(t, 1, len(dc), "unexpected number of containers")
hc := dc[0].Config.Healthcheck
assert.Assert(t, hc != nil, "healthcheck config should not be nil")
assert.Assert(t, len(hc.Test) >= 1, "healthcheck test should have at least 1 element")
assert.Equal(t, "NONE", hc.Test[0])
}),
}
}

testCase.Cleanup = func(data test.Data, helpers test.Helpers) {
if data.Labels().Get("composePath") != "" {
helpers.Anyhow("compose", "-f", data.Labels().Get("composePath"), "down", "-v")
}
}

testCase.Run(t)
}
2 changes: 1 addition & 1 deletion docs/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ which was derived from [Docker Compose file version 3 specification](https://doc
- `services.<SERVICE>.deploy.resources.reservations`
- `services.<SERVICE>.deploy.placement`
- `services.<SERVICE>.deploy.endpoint_mode`
- `services.<SERVICE>.healthcheck`
- `services.<SERVICE>.healthcheck.start_interval`
- `services.<SERVICE>.stop_grace_period`
- `services.<SERVICE>.stop_signal`
- `configs.<CONFIG>.external`
Expand Down
57 changes: 57 additions & 0 deletions pkg/composer/serviceparser/serviceparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (

"github.com/containerd/log"

"github.com/containerd/nerdctl/v2/pkg/healthcheck"
"github.com/containerd/nerdctl/v2/pkg/identifiers"
"github.com/containerd/nerdctl/v2/pkg/reflectutil"
)
Expand Down Expand Up @@ -78,6 +79,7 @@ func warnUnknownFields(svc types.ServiceConfig) {
"Extends", // handled by the loader
"Extensions",
"ExtraHosts",
"HealthCheck",
"Hostname",
"Image",
"Init",
Expand Down Expand Up @@ -121,6 +123,21 @@ func warnUnknownFields(svc types.ServiceConfig) {
}
}

if svc.HealthCheck != nil {
if unknown := reflectutil.UnknownNonEmptyFields(svc.HealthCheck,
"Test",
"Timeout",
"Interval",
"Retries",
"StartPeriod",
"Disable",
"Extensions",
// TODO: add support 'StartInterval'
); len(unknown) > 0 {
log.L.Warnf("Ignoring: service %s: healthcheck: %+v", svc.Name, unknown)
}
}

for depName, dep := range svc.DependsOn {
if unknown := reflectutil.UnknownNonEmptyFields(&dep,
"Condition",
Expand Down Expand Up @@ -747,6 +764,46 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e
c.RunArgs = append(c.RunArgs, "-w="+svc.WorkingDir)
}

if svc.HealthCheck != nil {
Comment thread
opjt marked this conversation as resolved.
hc := svc.HealthCheck
disabled := hc.Disable

if !disabled && len(hc.Test) > 0 {
switch hc.Test[0] {
case healthcheck.CmdNone:
disabled = true
case healthcheck.CmdShell:
if len(hc.Test) >= 2 {
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-cmd=%s", hc.Test[1]))
}
case healthcheck.Cmd:
// CMD exec form is converted to CMD-SHELL because --health-cmd always stores
// the command as CMD-SHELL (see pkg/cmd/container/create.go: withHealthcheck).
// This means the command will be executed via /bin/sh -c instead of exec directly.
if len(hc.Test) >= 2 {
log.L.Warnf("service %s: healthcheck: CMD exec form is not supported, converting to CMD-SHELL", svc.Name)
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-cmd=%s", strings.Join(hc.Test[1:], " ")))
}
}
}
if disabled {
c.RunArgs = append(c.RunArgs, "--no-healthcheck")
} else {
if hc.Interval != nil {
Comment thread
opjt marked this conversation as resolved.
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-interval=%s", time.Duration(*hc.Interval).String()))
}
if hc.Timeout != nil {
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-timeout=%s", time.Duration(*hc.Timeout).String()))
}
if hc.Retries != nil {
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-retries=%d", *hc.Retries))
}
if hc.StartPeriod != nil {
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-start-period=%s", time.Duration(*hc.StartPeriod).String()))
}
}
}

c.RunArgs = append(c.RunArgs, parsed.Image) // NOT svc.Image
c.RunArgs = append(c.RunArgs, svc.Command...)
return &c, nil
Expand Down
84 changes: 71 additions & 13 deletions pkg/composer/serviceparser/serviceparser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (
"fmt"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"

"github.com/compose-spec/compose-go/v2/types"
Expand All @@ -31,6 +33,15 @@ import (
"github.com/containerd/nerdctl/v2/pkg/testutil"
)

func getContainersFromService(t *testing.T, project *types.Project, svcName string) []Container {
t.Helper()
svcConfig, err := project.GetService(svcName)
assert.NilError(t, err)
svc, err := Parse(project, svcConfig)
assert.NilError(t, err)
return svc.Containers
}

func TestServicePortConfigToFlagP(t *testing.T) {
t.Parallel()
type testCase struct {
Expand Down Expand Up @@ -601,25 +612,72 @@ services:
project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil)
assert.NilError(t, err)

getContainersFromService := func(svcName string) []Container {
svcConfig, err := project.GetService(svcName)
assert.NilError(t, err)
svc, err := Parse(project, svcConfig)
assert.NilError(t, err)

return svc.Containers
}

var c Container
c = getContainersFromService("onfailure_no_count")[0]
c = getContainersFromService(t, project, "onfailure_no_count")[0]
assert.Assert(t, in(c.RunArgs, "--restart=on-failure"))

c = getContainersFromService("onfailure_with_count")[0]
c = getContainersFromService(t, project, "onfailure_with_count")[0]
assert.Assert(t, in(c.RunArgs, "--restart=on-failure:10"))

c = getContainersFromService("onfailure_ignore")[0]
c = getContainersFromService(t, project, "onfailure_ignore")[0]
assert.Assert(t, !in(c.RunArgs, "--restart=on-failure:3.14"))

c = getContainersFromService("unless_stopped")[0]
c = getContainersFromService(t, project, "unless_stopped")[0]
assert.Assert(t, in(c.RunArgs, "--restart=unless-stopped"))
}

func TestParseHealthCheck(t *testing.T) {
t.Parallel()
const dockerComposeYAML = `
services:
cmd_shell:
image: alpine:3.14
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
cmd_exec:
image: alpine:3.14
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m
disabled_flag:
image: alpine:3.14
healthcheck:
disable: true
test: ["CMD", "curl", "-f", "http://localhost"]
disabled_none:
image: alpine:3.14
healthcheck:
test: ["NONE"]
`
comp := testutil.NewComposeDir(t, dockerComposeYAML)
defer comp.CleanUp()

project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil)
assert.NilError(t, err)

var c Container

c = getContainersFromService(t, project, "cmd_shell")[0]
assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost || exit 1"))
assert.Assert(t, in(c.RunArgs, "--health-interval=30s"))
assert.Assert(t, in(c.RunArgs, "--health-timeout=10s"))
assert.Assert(t, in(c.RunArgs, "--health-retries=3"))
assert.Assert(t, in(c.RunArgs, "--health-start-period=5s"))

c = getContainersFromService(t, project, "cmd_exec")[0]
assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost"))
assert.Assert(t, in(c.RunArgs, "--health-interval=1m0s"))

c = getContainersFromService(t, project, "disabled_flag")[0]
assert.Assert(t, in(c.RunArgs, "--no-healthcheck"))
assert.Assert(t, !slices.ContainsFunc(c.RunArgs, func(s string) bool {
return strings.HasPrefix(s, "--health-cmd=")
}))

c = getContainersFromService(t, project, "disabled_none")[0]
assert.Assert(t, in(c.RunArgs, "--no-healthcheck"))
}
Loading