mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
* feat: error on unsupported compose features This implements a check for the unimplemented features of the uncloud support matrix and points to the matrix in the error. Fixes: #237 This redos #277, as I messed up my branch, (basically rebase gone wrong). Signed-off-by: Miek Gieben <miek@miek.nl> * Fix tests by capturing stderr The error should be nil, but we _do_ want to know if the user saw an warning. Redirect stderr to capture this as the error is emited by printing to stderr. Signed-off-by: Miek Gieben <miek@miek.nl> --------- Signed-off-by: Miek Gieben <miek@miek.nl>
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package compose
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/compose-spec/compose-go/v2/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/mount"
|
|
"github.com/opencontainers/go-digest"
|
|
"github.com/psviderski/uncloud/pkg/api"
|
|
cdi "tags.cncf.io/container-device-interface/pkg/parser"
|
|
)
|
|
|
|
func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.ServiceSpec, error) {
|
|
service, err := project.GetService(serviceName)
|
|
if err != nil {
|
|
return api.ServiceSpec{}, fmt.Errorf("get config for compose service '%s': %w", serviceName, err)
|
|
}
|
|
|
|
pullPolicy := ""
|
|
switch service.PullPolicy {
|
|
case types.PullPolicyAlways:
|
|
pullPolicy = api.PullPolicyAlways
|
|
case "", types.PullPolicyMissing, types.PullPolicyIfNotPresent:
|
|
pullPolicy = api.PullPolicyMissing
|
|
case types.PullPolicyNever:
|
|
pullPolicy = api.PullPolicyNever
|
|
default:
|
|
return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy)
|
|
}
|
|
|
|
env := make(map[string]string, len(service.Environment))
|
|
for k, v := range service.Environment {
|
|
if v == nil {
|
|
// nil value means the variable misses a value in the compose file, and it hasn't been resolved
|
|
// to a variable from the local environment running this code.
|
|
continue
|
|
}
|
|
env[k] = *v
|
|
}
|
|
|
|
spec := api.ServiceSpec{
|
|
Container: api.ContainerSpec{
|
|
CapAdd: service.CapAdd,
|
|
CapDrop: service.CapDrop,
|
|
Command: service.Command,
|
|
Entrypoint: service.Entrypoint,
|
|
Env: env,
|
|
Healthcheck: healthcheckFromCompose(service.HealthCheck),
|
|
Image: service.Image,
|
|
Init: service.Init,
|
|
PidMode: service.Pid,
|
|
Privileged: service.Privileged,
|
|
PullPolicy: pullPolicy,
|
|
Resources: resourcesFromCompose(service),
|
|
Sysctls: service.Sysctls,
|
|
User: service.User,
|
|
},
|
|
Name: serviceName,
|
|
Mode: api.ServiceModeReplicated,
|
|
}
|
|
|
|
// Map x-caddy extension to spec.Caddy if specified.
|
|
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
|
|
spec.Caddy = &api.CaddySpec{
|
|
Config: caddy.Config,
|
|
}
|
|
}
|
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
|
|
spec.Ports = ports
|
|
}
|
|
|
|
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
|
spec.Placement.Machines = machines
|
|
}
|
|
|
|
// Map LogDriver if specified
|
|
if service.Logging != nil && service.Logging.Driver != "" {
|
|
spec.Container.LogDriver = &api.LogDriver{
|
|
Name: service.Logging.Driver,
|
|
Options: service.Logging.Options,
|
|
}
|
|
}
|
|
|
|
if service.StopGracePeriod != nil {
|
|
d := time.Duration(*service.StopGracePeriod)
|
|
spec.Container.StopGracePeriod = &d
|
|
}
|
|
|
|
if service.Scale != nil {
|
|
spec.Replicas = uint(*service.Scale)
|
|
}
|
|
|
|
if service.Deploy != nil {
|
|
switch service.Deploy.Mode {
|
|
case "global":
|
|
spec.Mode = api.ServiceModeGlobal
|
|
case "", "replicated":
|
|
if service.Deploy.Replicas != nil {
|
|
spec.Replicas = uint(*service.Deploy.Replicas)
|
|
}
|
|
default:
|
|
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
|
}
|
|
|
|
if cfg := service.Deploy.UpdateConfig; cfg != nil {
|
|
switch cfg.Order {
|
|
case "":
|
|
// No order specified, use default behavior.
|
|
case "start-first":
|
|
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
|
|
case "stop-first":
|
|
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
|
|
default:
|
|
return spec, fmt.Errorf("unsupported deploy.update_config.order: '%s'", cfg.Order)
|
|
}
|
|
|
|
d := time.Duration(cfg.Monitor)
|
|
spec.UpdateConfig.MonitorPeriod = &d
|
|
}
|
|
}
|
|
|
|
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
|
volumeSpecs, volumeMounts, err := volumeSpecsFromCompose(project.Volumes, service.Volumes)
|
|
if err != nil {
|
|
return spec, err
|
|
}
|
|
|
|
spec.Volumes = volumeSpecs
|
|
spec.Container.VolumeMounts = volumeMounts
|
|
|
|
// Parse configs
|
|
configSpecs, configMounts, err := configSpecsFromCompose(project.Configs, service.Configs, project.WorkingDir)
|
|
if err != nil {
|
|
return spec, err
|
|
}
|
|
|
|
spec.Configs = configSpecs
|
|
spec.Container.ConfigMounts = configMounts
|
|
|
|
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),
|
|
Memory: int64(service.MemLimit),
|
|
MemoryReservation: int64(service.MemReservation),
|
|
SharedMemory: int64(service.ShmSize),
|
|
Ulimits: ulimitsFromCompose(service.Ulimits),
|
|
}
|
|
|
|
// Convert device mappings, separating CDI devices from regular device mappings.
|
|
// CDI devices are identified when Source == Target and the source is a qualified CDI name.
|
|
var cdiDeviceNames []string
|
|
for _, dev := range service.Devices {
|
|
if dev.Source == dev.Target && cdi.IsQualifiedName(dev.Source) {
|
|
cdiDeviceNames = append(cdiDeviceNames, dev.Source)
|
|
continue
|
|
}
|
|
resources.Devices = append(resources.Devices, api.DeviceMapping{
|
|
HostPath: dev.Source,
|
|
ContainerPath: dev.Target,
|
|
CgroupPermissions: dev.Permissions,
|
|
})
|
|
}
|
|
if len(cdiDeviceNames) > 0 {
|
|
resources.DeviceReservations = append(resources.DeviceReservations, container.DeviceRequest{
|
|
Driver: "cdi",
|
|
DeviceIDs: cdiDeviceNames,
|
|
})
|
|
}
|
|
|
|
// Convert GPU device requests from compose format, appending "gpu" capability.
|
|
resources.DeviceReservations = append(resources.DeviceReservations,
|
|
deviceReservationsFromCompose(service.Gpus, "gpu")...)
|
|
|
|
// Map resources from deploy section if specified.
|
|
if service.Deploy != nil {
|
|
if service.Deploy.Resources.Limits != nil {
|
|
if service.Deploy.Resources.Limits.NanoCPUs > 0 {
|
|
// It seems Limits.NanoCPUs is actually not nano CPUs but a CPU fraction.
|
|
resources.CPU = int64(service.Deploy.Resources.Limits.NanoCPUs * 1e9)
|
|
}
|
|
if service.Deploy.Resources.Limits.MemoryBytes > 0 {
|
|
resources.Memory = int64(service.Deploy.Resources.Limits.MemoryBytes)
|
|
}
|
|
}
|
|
if service.Deploy.Resources.Reservations != nil {
|
|
if service.Deploy.Resources.Reservations.MemoryBytes > 0 {
|
|
resources.MemoryReservation = int64(service.Deploy.Resources.Reservations.MemoryBytes)
|
|
}
|
|
// Handle arbitrary device reservations (same structure as Gpus above).
|
|
resources.DeviceReservations = append(resources.DeviceReservations,
|
|
deviceReservationsFromCompose(service.Deploy.Resources.Reservations.Devices)...)
|
|
}
|
|
}
|
|
|
|
return resources
|
|
}
|
|
|
|
// Converts compose-go DeviceRequest format to Docker API DeviceRequest format.
|
|
// Additional capabilities can be appended via extraCapabilities (e.g., "gpu" for service.Gpus).
|
|
func deviceReservationsFromCompose(devices []types.DeviceRequest, extraCapabilities ...string) []container.DeviceRequest {
|
|
if devices == nil {
|
|
return nil
|
|
}
|
|
|
|
requests := make([]container.DeviceRequest, 0, len(devices))
|
|
for _, deviceRequest := range devices {
|
|
// Docker expects an OR'd list of AND'd capabilities (e.g. [][]string),
|
|
// but compose-go provides a single AND'd list (e.g. []string).
|
|
capabilities := [][]string{append(deviceRequest.Capabilities, extraCapabilities...)}
|
|
|
|
spec := container.DeviceRequest{
|
|
Driver: deviceRequest.Driver,
|
|
Count: int(deviceRequest.Count),
|
|
DeviceIDs: deviceRequest.IDs,
|
|
Capabilities: capabilities,
|
|
Options: deviceRequest.Options,
|
|
}
|
|
requests = append(requests, spec)
|
|
}
|
|
return requests
|
|
}
|
|
|
|
func volumeSpecsFromCompose(
|
|
volumes types.Volumes, serviceVolumes []types.ServiceVolumeConfig,
|
|
) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
|
volumeSpecs := make(map[string]api.VolumeSpec)
|
|
var volumeMounts []api.VolumeMount
|
|
|
|
for _, serviceVolume := range serviceVolumes {
|
|
var volSpec api.VolumeSpec
|
|
|
|
switch serviceVolume.Type {
|
|
case types.VolumeTypeBind:
|
|
volSpec = bindVolumeSpecFromCompose(serviceVolume)
|
|
case types.VolumeTypeVolume:
|
|
volSpec = dockerVolumeSpecFromCompose(serviceVolume, volumes[serviceVolume.Source])
|
|
case types.VolumeTypeTmpfs:
|
|
volSpec = tmpfsVolumeSpecFromCompose(serviceVolume)
|
|
default:
|
|
return nil, nil, fmt.Errorf("unsupported volume type: '%s'", serviceVolume.Type)
|
|
}
|
|
|
|
if existing, ok := volumeSpecs[volSpec.Name]; ok {
|
|
if !existing.Equals(volSpec) {
|
|
return nil, nil, fmt.Errorf("volume '%s' is used multiple times with different options", volSpec.Name)
|
|
}
|
|
} else {
|
|
volumeSpecs[volSpec.Name] = volSpec
|
|
}
|
|
|
|
volumeMounts = append(volumeMounts, api.VolumeMount{
|
|
VolumeName: volSpec.Name,
|
|
ContainerPath: serviceVolume.Target,
|
|
ReadOnly: serviceVolume.ReadOnly,
|
|
})
|
|
}
|
|
|
|
return slices.Collect(maps.Values(volumeSpecs)), volumeMounts, nil
|
|
}
|
|
|
|
func bindVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.VolumeSpec {
|
|
// compose-go parser deduplicates volumes by the target path so it's safe to use it as the unique name.
|
|
name := "bind-" + digest.SHA256.FromString(serviceVolume.Target).Encoded()
|
|
spec := api.VolumeSpec{
|
|
Name: name,
|
|
Type: api.VolumeTypeBind,
|
|
BindOptions: &api.BindOptions{
|
|
HostPath: serviceVolume.Source,
|
|
},
|
|
}
|
|
if serviceVolume.Bind != nil {
|
|
spec.BindOptions.CreateHostPath = serviceVolume.Bind.CreateHostPath
|
|
spec.BindOptions.Propagation = mount.Propagation(serviceVolume.Bind.Propagation)
|
|
spec.BindOptions.Recursive = serviceVolume.Bind.Recursive
|
|
}
|
|
|
|
return spec
|
|
}
|
|
|
|
func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume types.VolumeConfig) api.VolumeSpec {
|
|
spec := api.VolumeSpec{
|
|
Name: serviceVolume.Source,
|
|
Type: api.VolumeTypeVolume,
|
|
VolumeOptions: &api.VolumeOptions{
|
|
Name: volume.Name,
|
|
},
|
|
}
|
|
|
|
if serviceVolume.Volume != nil {
|
|
spec.VolumeOptions.NoCopy = serviceVolume.Volume.NoCopy
|
|
spec.VolumeOptions.SubPath = serviceVolume.Volume.Subpath
|
|
}
|
|
|
|
if !volume.External {
|
|
if volume.Driver != "" || len(volume.DriverOpts) > 0 {
|
|
spec.VolumeOptions.Driver = &mount.Driver{
|
|
Name: volume.Driver,
|
|
Options: volume.DriverOpts,
|
|
}
|
|
}
|
|
|
|
labels := mergeLabels(volume.Labels, volume.CustomLabels)
|
|
if len(labels) > 0 {
|
|
spec.VolumeOptions.Labels = labels
|
|
}
|
|
}
|
|
|
|
return spec
|
|
}
|
|
|
|
func mergeLabels(labels ...types.Labels) types.Labels {
|
|
merged := types.Labels{}
|
|
for _, l := range labels {
|
|
maps.Copy(merged, l)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.VolumeSpec {
|
|
// compose-go parser deduplicates volumes by the target path so it's safe to use it as the unique name.
|
|
name := "tmpfs-" + digest.SHA256.FromString(serviceVolume.Target).Encoded()
|
|
spec := api.VolumeSpec{
|
|
Name: name,
|
|
Type: api.VolumeTypeTmpfs,
|
|
TmpfsOptions: &mount.TmpfsOptions{
|
|
SizeBytes: int64(serviceVolume.Tmpfs.Size),
|
|
Mode: os.FileMode(serviceVolume.Tmpfs.Mode),
|
|
},
|
|
}
|
|
|
|
return spec
|
|
}
|
|
|
|
func ulimitsFromCompose(ulimits map[string]*types.UlimitsConfig) map[string]api.Ulimit {
|
|
if len(ulimits) == 0 {
|
|
return nil
|
|
}
|
|
|
|
res := make(map[string]api.Ulimit, len(ulimits))
|
|
for name, u := range ulimits {
|
|
soft := u.Soft
|
|
hard := u.Hard
|
|
if u.Single != 0 {
|
|
if soft == 0 {
|
|
soft = u.Single
|
|
}
|
|
if hard == 0 {
|
|
hard = u.Single
|
|
}
|
|
}
|
|
|
|
res[name] = api.Ulimit{
|
|
Soft: int64(soft),
|
|
Hard: int64(hard),
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// validateServicesExtensions validates extension combinations across all services in the project.
|
|
func validateServicesExtensions(project *types.Project) error {
|
|
for _, service := range project.Services {
|
|
// Check for x-caddy and x-ports conflict, unless all ports are host mode.
|
|
hasCaddy := false
|
|
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
|
|
hasCaddy = true
|
|
}
|
|
|
|
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok && len(ports) > 0 && hasCaddy {
|
|
// Check if all ports are in host mode.
|
|
hasIngressPort := false
|
|
for _, p := range ports {
|
|
if p.Mode == "" || p.Mode == api.PortModeIngress {
|
|
hasIngressPort = true
|
|
break
|
|
}
|
|
}
|
|
if hasIngressPort {
|
|
return fmt.Errorf("service '%s': ingress ports in 'x-ports' and 'x-caddy' cannot be specified "+
|
|
"simultaneously: Caddy config is auto-generated from ingress ports, use only one of them. "+
|
|
"Host mode ports in 'x-caddy' can be used with 'x-caddy'", service.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateServicesFeatures checks the service for unsupported features and returns an error for this first one found.
|
|
func validateServicesFeatures(project *types.Project) error {
|
|
err := func(service, feature string) error {
|
|
return fmt.Errorf("service: '%s': unsupported feature: '%s', see %s", service, feature, "https://uncloud.run/docs/compose-file-reference/support-matrix")
|
|
}
|
|
|
|
for _, service := range project.Services {
|
|
if service.SecurityOpt != nil {
|
|
return err(service.Name, "security_opt")
|
|
}
|
|
if service.DNS != nil {
|
|
return err(service.Name, "dns")
|
|
}
|
|
if service.DNSSearch != nil {
|
|
return err(service.Name, "dns_search")
|
|
}
|
|
if service.Labels != nil {
|
|
return err(service.Name, "labels")
|
|
}
|
|
if service.Links != nil {
|
|
return err(service.Name, "links")
|
|
}
|
|
if service.MemSwappiness > 0 {
|
|
return err(service.Name, "mem_swappiness")
|
|
}
|
|
if service.MemSwapLimit > 0 {
|
|
return err(service.Name, "memswap_limit")
|
|
}
|
|
if service.Secrets != nil {
|
|
return err(service.Name, "secrets")
|
|
}
|
|
if service.StorageOpt != nil {
|
|
return err(service.Name, "storage_opt")
|
|
}
|
|
// we only allow the 'default' network, nothing else.
|
|
if x := service.Networks; x != nil {
|
|
if len(x) != 1 {
|
|
return err(service.Name, "networks")
|
|
}
|
|
config, ok := x["default"]
|
|
if !ok || config != nil {
|
|
return err(service.Name, "networks")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|