diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 2f74875e..41ae5391 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -567,6 +567,22 @@ func (s *Server) CreateServiceContainer( if spec.Mode == "" { config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated } + if hc := spec.Container.Healthcheck; hc != nil { + if hc.Disable { + config.Healthcheck = &container.HealthConfig{ + Test: []string{"NONE"}, + } + } else { + config.Healthcheck = &container.HealthConfig{ + Test: hc.Test, + Interval: hc.Interval, + Timeout: hc.Timeout, + StartPeriod: hc.StartPeriod, + StartInterval: hc.StartInterval, + Retries: int(hc.Retries), + } + } + } // TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB. var err error diff --git a/pkg/api/service.go b/pkg/api/service.go index 09c2561e..4697e4a3 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -7,6 +7,7 @@ import ( "regexp" "slices" "strings" + "time" mapset "github.com/deckarep/golang-set/v2" "github.com/distribution/reference" @@ -74,14 +75,6 @@ type ServiceSpec struct { Configs []ConfigSpec } -// UpdateConfig configures how a service is updated during a deployment. -type UpdateConfig struct { - // Order specifies the order of operations during an update. - // Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with volumes). - // Empty value means the strategy will determine the order based on service characteristics. - Order string `json:",omitempty"` -} - // CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined. func (s *ServiceSpec) CaddyConfig() string { if s.Caddy == nil { @@ -251,8 +244,11 @@ type ContainerSpec struct { // Entrypoint overrides the default ENTRYPOINT of the image. Entrypoint []string // Env defines the environment variables to set inside the container. - Env EnvVars - Image string + Env EnvVars + // Healthcheck defines the health check configuration for the container or overrides the health check options + // defined in the image. If nil, the image's default health check is used. + Healthcheck *HealthcheckSpec `json:",omitempty"` + Image string // Run a custom init inside the container. If nil, use the daemon's configured settings. Init *bool // LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default. @@ -347,6 +343,17 @@ func (s *ContainerSpec) Clone() ContainerSpec { spec.Entrypoint = make([]string, len(s.Entrypoint)) copy(spec.Entrypoint, s.Entrypoint) } + if s.Env != nil { + spec.Env = make(EnvVars, len(s.Env)) + for k, v := range s.Env { + spec.Env[k] = v + } + } + if s.Healthcheck != nil { + hc := *s.Healthcheck + hc.Test = slices.Clone(s.Healthcheck.Test) + spec.Healthcheck = &hc + } if s.LogDriver != nil { logDriver := *s.LogDriver if s.LogDriver.Options != nil { @@ -354,12 +361,6 @@ func (s *ContainerSpec) Clone() ContainerSpec { } spec.LogDriver = &logDriver } - if s.Env != nil { - spec.Env = make(EnvVars, len(s.Env)) - for k, v := range s.Env { - spec.Env[k] = v - } - } if s.Volumes != nil { spec.Volumes = make([]string, len(s.Volumes)) copy(spec.Volumes, s.Volumes) @@ -389,6 +390,7 @@ func (s *ContainerSpec) Clone() ContainerSpec { if s.Resources.DeviceReservations != nil { spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations) } + return spec } @@ -406,6 +408,26 @@ func (e EnvVars) ToSlice() []string { return env } +// HealthcheckSpec defines the health check configuration for a container. +type HealthcheckSpec struct { + // Test is the command used to check health. + // Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable. + Test []string `json:",omitempty"` + // Interval is the time between health checks. + // Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image. + Interval time.Duration `json:",omitempty"` + // Timeout is how long to wait before considering the checck to have hung. + Timeout time.Duration `json:",omitempty"` + // StartPeriod is the initialisation time for a container before the retries start to count down. + StartPeriod time.Duration `json:",omitempty"` + // StartInterval is the time between health checks during the start period. + StartInterval time.Duration `json:",omitempty"` + // Retries is the number of consecutive failures needed to consider a container unhealthy. + Retries uint `json:",omitempty"` + // Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"]. + Disable bool `json:",omitempty"` +} + type LogDriver struct { // Name of the logging driver to use. Name string @@ -413,6 +435,14 @@ type LogDriver struct { Options map[string]string } +// UpdateConfig configures how a service is updated during a deployment. +type UpdateConfig struct { + // Order specifies the order of operations during an update. + // Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with volumes). + // Empty value means the strategy will determine the order based on service characteristics. + Order string `json:",omitempty"` +} + type RunServiceResponse struct { ID string Name string diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 2a33a9a5..83cbce65 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -5,6 +5,7 @@ import ( "maps" "os" "slices" + "time" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/docker/api/types/container" @@ -44,18 +45,19 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser spec := api.ServiceSpec{ Container: api.ContainerSpec{ - CapAdd: service.CapAdd, - CapDrop: service.CapDrop, - Command: service.Command, - Entrypoint: service.Entrypoint, - Env: env, - Image: service.Image, - Init: service.Init, - Privileged: service.Privileged, - PullPolicy: pullPolicy, - Resources: resourcesFromCompose(service), - Sysctls: service.Sysctls, - User: service.User, + CapAdd: service.CapAdd, + CapDrop: service.CapDrop, + Command: service.Command, + Entrypoint: service.Entrypoint, + Env: env, + Healthcheck: healthcheckFromCompose(service.HealthCheck), + Image: service.Image, + Init: service.Init, + Privileged: service.Privileged, + PullPolicy: pullPolicy, + Resources: resourcesFromCompose(service), + Sysctls: service.Sysctls, + User: service.User, }, Name: serviceName, Mode: api.ServiceModeReplicated, @@ -135,6 +137,34 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser return spec, nil } +func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec { + if hc == nil { + return nil + } + if hc.Disable { + return &api.HealthcheckSpec{Disable: true} + } + + spec := &api.HealthcheckSpec{Test: hc.Test} + if hc.Interval != nil { + spec.Interval = time.Duration(*hc.Interval) + } + if hc.Timeout != nil { + spec.Timeout = time.Duration(*hc.Timeout) + } + if hc.StartPeriod != nil { + spec.StartPeriod = time.Duration(*hc.StartPeriod) + } + if hc.StartInterval != nil { + spec.StartInterval = time.Duration(*hc.StartInterval) + } + if hc.Retries != nil { + spec.Retries = uint(*hc.Retries) + } + + return spec +} + func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources { resources := api.ContainerResources{ CPU: int64(service.CPUS * 1e9), diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index e746d605..a476b33c 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "testing" + "time" composecli "github.com/compose-spec/compose-go/v2/cli" "github.com/docker/docker/api/types/container" @@ -109,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) { "EMPTY": "", "VAR": "value", }, + Healthcheck: &api.HealthcheckSpec{ + Test: []string{"CMD", "curl", "-f", "http://localhost"}, + Interval: 1*time.Minute + 30*time.Second, + Timeout: 10 * time.Second, + Retries: 5, + StartPeriod: 15 * time.Second, + StartInterval: 2 * time.Second, + }, Image: "nginx:latest", Init: &initTrue, LogDriver: &api.LogDriver{ diff --git a/pkg/client/compose/testdata/compose-full-spec.yaml b/pkg/client/compose/testdata/compose-full-spec.yaml index c7fb89d7..9f024f98 100644 --- a/pkg/client/compose/testdata/compose-full-spec.yaml +++ b/pkg/client/compose/testdata/compose-full-spec.yaml @@ -11,6 +11,13 @@ services: BOOL: "true" EMPTY: "" VAR: value + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost"] + interval: 1m30s + timeout: 10s + retries: 5 + start_period: 15s + start_interval: 2s image: nginx:latest init: true logging: diff --git a/test/e2e/assert.go b/test/e2e/assert.go index f6569c8c..93191d65 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -58,6 +58,36 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api assert.Contains(t, ctr.Config.Env, env) } + // Healthcheck can only be compared if set in the spec. Otherwise, the container inherits it from the image. + if spec.Container.Healthcheck != nil { + hc := spec.Container.Healthcheck + require.NotNil(t, ctr.Config.Healthcheck) + if hc.Disable { + assert.Equal(t, []string{"NONE"}, ctr.Config.Healthcheck.Test) + } else { + // Only compare fields that are explicitly set in the spec as unset fields inherit their values + // from the image. + if hc.Test != nil { + assert.EqualValues(t, hc.Test, ctr.Config.Healthcheck.Test) + } + if hc.Interval != 0 { + assert.Equal(t, hc.Interval, ctr.Config.Healthcheck.Interval) + } + if hc.Timeout != 0 { + assert.Equal(t, hc.Timeout, ctr.Config.Healthcheck.Timeout) + } + if hc.StartPeriod != 0 { + assert.Equal(t, hc.StartPeriod, ctr.Config.Healthcheck.StartPeriod) + } + if hc.StartInterval != 0 { + assert.Equal(t, hc.StartInterval, ctr.Config.Healthcheck.StartInterval) + } + if hc.Retries != 0 { + assert.Equal(t, int(hc.Retries), ctr.Config.Healthcheck.Retries) + } + } + } + assert.Equal(t, spec.Container.Image, ctr.Config.Image) assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init) assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-")) diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 240c73f0..43659a81 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -1315,6 +1315,7 @@ func TestServiceLifecycle(t *testing.T) { Name: "container-spec-full", Mode: api.ServiceModeGlobal, Container: api.ContainerSpec{ + // TODO: Add the latest implemented fields to this spec and update assertContainerMatchesSpec. Command: []string{"sleep", "infinity"}, // Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause. Entrypoint: []string{"///pause"}, @@ -1324,6 +1325,14 @@ func TestServiceLifecycle(t *testing.T) { "BOOL": "true", "": "ignored", }, + Healthcheck: &api.HealthcheckSpec{ + Test: []string{"CMD-SHELL", "exit 0"}, + Interval: 1*time.Minute + 30*time.Second, + Timeout: 10 * time.Second, + Retries: 5, + StartPeriod: 15 * time.Second, + StartInterval: 2 * time.Second, + }, Image: "portainer/pause:latest", Init: &init, LogDriver: &api.LogDriver{ diff --git a/website/docs/8-compose-file-reference/1-support-matrix.md b/website/docs/8-compose-file-reference/1-support-matrix.md index 98334044..7cf57924 100644 --- a/website/docs/8-compose-file-reference/1-support-matrix.md +++ b/website/docs/8-compose-file-reference/1-support-matrix.md @@ -3,67 +3,68 @@ Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations. The following table shows the support status for main Compose features: -| Feature | Support Status | Notes | -|--------------------|--------------------|------------------------------------------------------------------------------------------------| -| **Services** | | | -| `build` | ✅ Supported | Build context and Dockerfile | -| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | -| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop | -| `command` | ✅ Supported | Override container command | -| `configs` | ✅ Supported | File-based and inline configs | -| `cpus` | ✅ Supported | CPU limit | -| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked | -| `devices` | ✅ Supported | Device mappings | -| `dns` | ❌ Not supported | Built-in service discovery | -| `dns_search` | ❌ Not supported | Built-in service discovery | -| `entrypoint` | ✅ Supported | Override container entrypoint | -| `env_file` | ✅ Supported | Environment file | -| `environment` | ✅ Supported | Environment variables | -| `gpus` | ✅ Supported | GPU device access | -| `image` | ✅ Supported | Container image specification | -| `init` | ✅ Supported | Run init process in container | -| `labels` | ❌ Not supported | | -| `links` | ❌ Not supported | Use service names for communication | -| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver | -| `mem_limit` | ✅ Supported | Memory limit | -| `mem_reservation` | ✅ Supported | Memory reservation | -| `mem_swappiness` | ❌ Not supported | | -| `memswap_limit` | ❌ Not supported | | -| `networks` | ❌ Not supported | All containers share cluster network | -| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS | -| `privileged` | ✅ Supported | Run containers in privileged mode | -| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | -| `secrets` | ❌ Not supported | Use configs or environment variables | -| `security_opt` | ❌ Not supported | | -| `storage_opt` | ❌ Not supported | | -| `sysctls` | ✅ Supported | Namespaced kernel parameters | -| `user` | ✅ Supported | Set container user | -| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | -| **Deploy** | | | -| `labels` | ❌ Not supported | | -| `mode` | ✅ Supported | Either `global` or `replicated` | -| `placement` | ❌ Not supported | Use `x-machines` extension | -| `replicas` | ✅ Supported | Number of container replicas | -| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | -| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | -| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | +| Feature | Support Status | Notes | +|--------------------|--------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| **Services** | | | +| `build` | ✅ Supported | Build context and Dockerfile | +| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | +| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop | +| `command` | ✅ Supported | Override container command | +| `configs` | ✅ Supported | File-based and inline configs | +| `cpus` | ✅ Supported | CPU limit | +| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked | +| `devices` | ✅ Supported | Device mappings | +| `dns` | ❌ Not supported | Built-in service discovery | +| `dns_search` | ❌ Not supported | Built-in service discovery | +| `entrypoint` | ✅ Supported | Override container entrypoint | +| `env_file` | ✅ Supported | Environment file | +| `environment` | ✅ Supported | Environment variables | +| `gpus` | ✅ Supported | GPU device access | +| `healthcheck` | ✅ Supported | Health check configuration | +| `image` | ✅ Supported | Container image specification | +| `init` | ✅ Supported | Run init process in container | +| `labels` | ❌ Not supported | | +| `links` | ❌ Not supported | Use service names for communication | +| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver | +| `mem_limit` | ✅ Supported | Memory limit | +| `mem_reservation` | ✅ Supported | Memory reservation | +| `mem_swappiness` | ❌ Not supported | | +| `memswap_limit` | ❌ Not supported | | +| `networks` | ❌ Not supported | All containers share cluster network | +| `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS | +| `privileged` | ✅ Supported | Run containers in privileged mode | +| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | +| `secrets` | ❌ Not supported | Use configs or environment variables | +| `security_opt` | ❌ Not supported | | +| `storage_opt` | ❌ Not supported | | +| `sysctls` | ✅ Supported | Namespaced kernel parameters | +| `user` | ✅ Supported | Set container user | +| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | +| **Deploy** | | | +| `labels` | ❌ Not supported | | +| `mode` | ✅ Supported | Either `global` or `replicated` | +| `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension | +| `replicas` | ✅ Supported | Number of container replicas | +| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | +| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | +| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | | `update_config` | ⚠️ Limited | Only `order` supported (defaults to `start-first`). See [deployment strategies](../4-guides/1-deployments/4-deployment-strategies.md) | -| **Volumes** | | | -| Named volumes | ✅ Supported | Docker volumes | -| Bind mounts | ✅ Supported | Host path binding | -| Tmpfs mounts | ✅ Supported | In-memory filesystems | -| Volume labels | ✅ Supported | Custom labels | -| External volumes | ✅ Supported | Must exist before deployment | -| Volume drivers | ⚠️ Limited | Local driver only | -| **Configs** | | | -| File-based configs | ✅ Supported | Read from file | -| Inline configs | ✅ Supported | Defined in compose file | -| External configs | ❌ Not supported | Not supported | -| Short syntax | ❌ Not supported | Use long syntax only | -| **Extensions** | | | -| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | -| `x-machines` | ✅ Uncloud-specific | Machine placement constraints | -| `x-ports` | ✅ Uncloud-specific | Service port publishing | +| **Volumes** | | | +| Named volumes | ✅ Supported | Docker volumes | +| Bind mounts | ✅ Supported | Host path binding | +| Tmpfs mounts | ✅ Supported | In-memory filesystems | +| Volume labels | ✅ Supported | Custom labels | +| External volumes | ✅ Supported | Must exist before deployment | +| Volume drivers | ⚠️ Limited | Local driver only | +| **Configs** | | | +| File-based configs | ✅ Supported | Read from file | +| Inline configs | ✅ Supported | Defined in compose file | +| External configs | ❌ Not supported | Not supported | +| Short syntax | ❌ Not supported | Use long syntax only | +| **Extensions** | | | +| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | +| `x-machines` | ✅ Uncloud-specific | Machine placement constraints | +| `x-ports` | ✅ Uncloud-specific | Service port publishing | ### Legend