diff --git a/go.mod b/go.mod index 2f16afb5..f426d7cf 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/siderolabs/discovery-client v0.1.9 github.com/siderolabs/grpc-proxy v0.5.1 github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.3.0 go.uber.org/zap v1.27.0 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -63,6 +64,7 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect @@ -147,6 +149,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -171,6 +174,7 @@ require ( golang.org/x/tools v0.26.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect lukechampine.com/blake3 v1.3.0 // indirect diff --git a/internal/machine/docker/container/container.go b/internal/machine/docker/container/container.go index 48dd2a70..8414b313 100644 --- a/internal/machine/docker/container/container.go +++ b/internal/machine/docker/container/container.go @@ -1,6 +1,9 @@ package container -import "github.com/docker/docker/api/types" +import ( + "github.com/docker/docker/api/types" + "regexp" +) const ( LabelServiceID = "uncloud.service.id" @@ -11,4 +14,34 @@ type Container struct { types.Container } -// TODO: implement health related methods. +// runningStatusRegex matches the status string of a running container. +// - "Up 3 minutes (healthy)" -> groups: ["Up 3 minutes (healthy)", "healthy"] +// - "Up 5 seconds" -> groups: ["Up 5 seconds", ""] +// - "Up 2 hours (unhealthy)" -> groups: ["Up 2 hours (unhealthy)", "unhealthy"] +// - "Up 1 minute (health: starting)" -> groups: ["Up 1 minute (health: starting)", "health: starting"] +// - "Restarting (0) 5 seconds ago" -> no match +// See https://github.com/moby/moby/blob/c130ce1f5d1e38b98a97044a39557de43bc0d58f/container/state.go#L77-L90 +// for more details on how the status string for a running container is formatted. +var runningStatusRegex = regexp.MustCompile(`^Up [^(]+(?:\(([^)]+)\))?$`) + +// Healthy determines if the container is running and healthy based on its status string. +// A running container with no health check configured is considered healthy. +func (c *Container) Healthy() bool { + if c.State != "running" { + return false + } + + matches := runningStatusRegex.FindStringSubmatch(c.Status) + // Not "Up" or invalid format. + if matches == nil { + return false + } + + // If there's no health status (no health check configured so no parentheses), container is considered healthy. + if matches[1] == "" { + return true + } + + // If the health status in parentheses is "healthy", the container is considered healthy. + return matches[1] == types.Healthy +} diff --git a/internal/machine/docker/container/container_test.go b/internal/machine/docker/container/container_test.go new file mode 100644 index 00000000..609fcc0f --- /dev/null +++ b/internal/machine/docker/container/container_test.go @@ -0,0 +1,92 @@ +package container + +import ( + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestContainer_Healthy(t *testing.T) { + t.Parallel() + + t.Run("exited", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "exited", + Status: "Exited (0) 2 minutes ago", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("running with no health check", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up 5 minutes", + }} + assert.True(t, c.Healthy()) + }) + + t.Run("running and healthy", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up 3 minutes (healthy)", + }} + assert.True(t, c.Healthy()) + }) + + t.Run("running but unhealthy", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up 2 hours (unhealthy)", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("running with health starting", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up 1 minute (health: starting)", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("invalid up format no time", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("invalid up format empty parentheses", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Up 5 minutes ()", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("malformed status", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Invalid status", + }} + assert.False(t, c.Healthy()) + }) + + t.Run("restarting", func(t *testing.T) { + t.Parallel() + c := &Container{Container: types.Container{ + State: "running", + Status: "Restarting (0) 5 seconds ago", + }} + assert.False(t, c.Healthy()) + }) +}