github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/cli/compose_validation.go (about)

     1  package cli
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strconv"
     9  	"strings"
    10  
    11  	compose "github.com/compose-spec/compose-go/v2/types"
    12  	"github.com/defang-io/defang/src/pkg"
    13  )
    14  
    15  func validateProject(project *compose.Project) error {
    16  	if project == nil {
    17  		return errors.New("no project found")
    18  	}
    19  	for _, svccfg := range project.Services {
    20  		normalized := NormalizeServiceName(svccfg.Name)
    21  		if !pkg.IsValidServiceName(normalized) {
    22  			// FIXME: this is too strict; we should allow more characters like underscores and dots
    23  			return fmt.Errorf("service name is invalid: %q", svccfg.Name)
    24  		}
    25  		if normalized != svccfg.Name {
    26  			warnf("service name %q was normalized to %q", svccfg.Name, normalized)
    27  		}
    28  		if svccfg.ReadOnly {
    29  			warnf("unsupported compose directive: read_only")
    30  		}
    31  		if svccfg.Restart == "" {
    32  			warnf("missing compose directive: restart; assuming 'unless-stopped' (add 'restart' to silence)")
    33  		} else if svccfg.Restart != "always" && svccfg.Restart != "unless-stopped" {
    34  			warnf("unsupported compose directive: restart; assuming 'unless-stopped' (add 'restart' to silence)")
    35  		}
    36  		if svccfg.ContainerName != "" {
    37  			warnf("unsupported compose directive: container_name")
    38  		}
    39  		if svccfg.Hostname != "" {
    40  			return fmt.Errorf("unsupported compose directive: hostname; consider using 'domainname' instead")
    41  		}
    42  		if len(svccfg.DNSSearch) != 0 {
    43  			return fmt.Errorf("unsupported compose directive: dns_search")
    44  		}
    45  		if len(svccfg.DNSOpts) != 0 {
    46  			warnf("unsupported compose directive: dns_opt")
    47  		}
    48  		if len(svccfg.DNS) != 0 {
    49  			return fmt.Errorf("unsupported compose directive: dns")
    50  		}
    51  		if len(svccfg.Devices) != 0 {
    52  			return fmt.Errorf("unsupported compose directive: devices")
    53  		}
    54  		if len(svccfg.DependsOn) != 0 {
    55  			warnf("unsupported compose directive: depends_on")
    56  		}
    57  		if len(svccfg.DeviceCgroupRules) != 0 {
    58  			return fmt.Errorf("unsupported compose directive: device_cgroup_rules")
    59  		}
    60  		if len(svccfg.Entrypoint) > 0 {
    61  			return fmt.Errorf("unsupported compose directive: entrypoint")
    62  		}
    63  		if len(svccfg.GroupAdd) > 0 {
    64  			return fmt.Errorf("unsupported compose directive: group_add")
    65  		}
    66  		if len(svccfg.Ipc) > 0 {
    67  			warnf("unsupported compose directive: ipc")
    68  		}
    69  		if len(svccfg.Uts) > 0 {
    70  			warnf("unsupported compose directive: uts")
    71  		}
    72  		if svccfg.Isolation != "" {
    73  			warnf("unsupported compose directive: isolation")
    74  		}
    75  		if svccfg.MacAddress != "" {
    76  			warnf("unsupported compose directive: mac_address")
    77  		}
    78  		if len(svccfg.Labels) > 0 {
    79  			warnf("unsupported compose directive: labels") // TODO: add support for labels
    80  		}
    81  		if len(svccfg.Links) > 0 {
    82  			warnf("unsupported compose directive: links")
    83  		}
    84  		if svccfg.Logging != nil {
    85  			warnf("unsupported compose directive: logging")
    86  		}
    87  		for name := range svccfg.Networks {
    88  			if _, ok := project.Networks[name]; !ok {
    89  				warnf("network %v used by service %v is not defined in the top-level networks section", name, svccfg.Name)
    90  			}
    91  		}
    92  		if len(svccfg.Volumes) > 0 {
    93  			warnf("unsupported compose directive: volumes") // TODO: add support for volumes
    94  		}
    95  		if len(svccfg.VolumesFrom) > 0 {
    96  			warnf("unsupported compose directive: volumes_from") // TODO: add support for volumes_from
    97  		}
    98  		if svccfg.Build != nil {
    99  			if svccfg.Build.Dockerfile != "" {
   100  				if filepath.IsAbs(svccfg.Build.Dockerfile) {
   101  					return fmt.Errorf("dockerfile path must be relative to the build context: %q", svccfg.Build.Dockerfile)
   102  				}
   103  				if strings.HasPrefix(svccfg.Build.Dockerfile, "../") {
   104  					return fmt.Errorf("dockerfile path must be inside the build context: %q", svccfg.Build.Dockerfile)
   105  				}
   106  				// Check if the dockerfile exists
   107  				dockerfilePath := filepath.Join(svccfg.Build.Context, svccfg.Build.Dockerfile)
   108  				if _, err := os.Stat(dockerfilePath); err != nil {
   109  					return fmt.Errorf("dockerfile not found: %q", dockerfilePath)
   110  				}
   111  			}
   112  			if svccfg.Build.SSH != nil {
   113  				return fmt.Errorf("unsupported compose directive: build ssh")
   114  			}
   115  			if len(svccfg.Build.Labels) != 0 {
   116  				warnf("unsupported compose directive: build labels") // TODO: add support for Kaniko --label
   117  			}
   118  			if len(svccfg.Build.CacheFrom) != 0 {
   119  				warnf("unsupported compose directive: build cache_from")
   120  			}
   121  			if len(svccfg.Build.CacheTo) != 0 {
   122  				warnf("unsupported compose directive: build cache_to")
   123  			}
   124  			if svccfg.Build.NoCache {
   125  				warnf("unsupported compose directive: build no_cache")
   126  			}
   127  			if len(svccfg.Build.ExtraHosts) != 0 {
   128  				return fmt.Errorf("unsupported compose directive: build extra_hosts")
   129  			}
   130  			if svccfg.Build.Isolation != "" {
   131  				warnf("unsupported compose directive: build isolation")
   132  			}
   133  			if svccfg.Build.Network != "" {
   134  				return fmt.Errorf("unsupported compose directive: build network")
   135  			}
   136  			if len(svccfg.Build.Secrets) != 0 {
   137  				return fmt.Errorf("unsupported compose directive: build secrets") // TODO: support build secrets
   138  			}
   139  			if len(svccfg.Build.Tags) != 0 {
   140  				return fmt.Errorf("unsupported compose directive: build tags")
   141  			}
   142  			if len(svccfg.Build.Platforms) != 0 {
   143  				return fmt.Errorf("unsupported compose directive: build platforms")
   144  			}
   145  			if svccfg.Build.Privileged {
   146  				return fmt.Errorf("unsupported compose directive: build privileged")
   147  			}
   148  			if svccfg.Build.DockerfileInline != "" {
   149  				return fmt.Errorf("unsupported compose directive: build dockerfile_inline")
   150  			}
   151  		}
   152  		for _, secret := range svccfg.Secrets {
   153  			if !pkg.IsValidSecretName(secret.Source) {
   154  				return fmt.Errorf("secret name is invalid: %q", secret.Source)
   155  			}
   156  			if secret.Target != "" {
   157  				return fmt.Errorf("unsupported compose directive: secret target")
   158  			}
   159  			if s, ok := project.Secrets[secret.Source]; !ok {
   160  				warnf("secret %q is not defined in the top-level secrets section", secret.Source)
   161  			} else if s.Name != "" && s.Name != secret.Source {
   162  				return fmt.Errorf("unsupported secret %q: cannot override name %q", secret.Source, s.Name) // TODO: support custom secret names
   163  			} else if !s.External {
   164  				warnf("unsupported secret %q: not marked external:true", secret.Source) // TODO: support secrets from environment/file
   165  			}
   166  		}
   167  		err := validatePorts(svccfg.Ports)
   168  		if err != nil {
   169  			return err
   170  		}
   171  		if svccfg.HealthCheck == nil || svccfg.HealthCheck.Disable {
   172  			// Show a warning when we have ingress ports but no explicit healthcheck
   173  			for _, port := range svccfg.Ports {
   174  				if port.Mode == "ingress" {
   175  					warnf("ingress port without healthcheck defaults to GET / HTTP/1.1")
   176  					break
   177  				}
   178  			}
   179  		} else {
   180  			timeout := 30 // default per compose spec
   181  			if svccfg.HealthCheck.Timeout != nil {
   182  				if *svccfg.HealthCheck.Timeout%1e9 != 0 {
   183  					warnf("healthcheck timeout must be a multiple of 1s")
   184  				}
   185  				timeout = int(*svccfg.HealthCheck.Timeout / 1e9)
   186  			}
   187  			interval := 30 // default per compose spec
   188  			if svccfg.HealthCheck.Interval != nil {
   189  				if *svccfg.HealthCheck.Interval%1e9 != 0 {
   190  					warnf("healthcheck interval must be a multiple of 1s")
   191  				}
   192  				interval = int(*svccfg.HealthCheck.Interval / 1e9)
   193  			}
   194  			// Technically this should test for <= but both interval and timeout have 30s as the default value
   195  			if interval < timeout || timeout <= 0 {
   196  				return fmt.Errorf("healthcheck timeout %ds must be positive and smaller than the interval %ds", timeout, interval)
   197  			}
   198  			if svccfg.HealthCheck.StartPeriod != nil {
   199  				warnf("unsupported compose directive: healthcheck start_period")
   200  			}
   201  			if svccfg.HealthCheck.StartInterval != nil {
   202  				warnf("unsupported compose directive: healthcheck start_interval")
   203  			}
   204  		}
   205  		if svccfg.Deploy != nil {
   206  			if svccfg.Deploy.Mode != "" && svccfg.Deploy.Mode != "replicated" {
   207  				return fmt.Errorf("unsupported compose directive: deploy mode: %q", svccfg.Deploy.Mode)
   208  			}
   209  			if len(svccfg.Deploy.Labels) > 0 {
   210  				warnf("unsupported compose directive: deploy labels")
   211  			}
   212  			if svccfg.Deploy.UpdateConfig != nil {
   213  				return fmt.Errorf("unsupported compose directive: deploy update_config")
   214  			}
   215  			if svccfg.Deploy.RollbackConfig != nil {
   216  				return fmt.Errorf("unsupported compose directive: deploy rollback_config")
   217  			}
   218  			if svccfg.Deploy.RestartPolicy != nil {
   219  				return fmt.Errorf("unsupported compose directive: deploy restart_policy")
   220  			}
   221  			if len(svccfg.Deploy.Placement.Constraints) != 0 || len(svccfg.Deploy.Placement.Preferences) != 0 || svccfg.Deploy.Placement.MaxReplicas != 0 {
   222  				warnf("unsupported compose directive: deploy placement")
   223  			}
   224  			if svccfg.Deploy.EndpointMode != "" {
   225  				return fmt.Errorf("unsupported compose directive: deploy endpoint_mode")
   226  			}
   227  			if svccfg.Deploy.Resources.Limits != nil && svccfg.Deploy.Resources.Reservations == nil {
   228  				warnf("no reservations specified; using limits as reservations")
   229  			}
   230  			reservations := getResourceReservations(svccfg.Deploy.Resources)
   231  			if reservations != nil && reservations.NanoCPUs != "" {
   232  				cpus, err := strconv.ParseFloat(reservations.NanoCPUs, 32)
   233  				if err != nil || cpus < 0 { // "0" just means "as small as possible"
   234  					return fmt.Errorf("invalid value for cpus: %q", reservations.NanoCPUs)
   235  				}
   236  			}
   237  		}
   238  		var reservations *compose.Resource
   239  		if svccfg.Deploy != nil {
   240  			reservations = getResourceReservations(svccfg.Deploy.Resources)
   241  		}
   242  
   243  		if svccfg.Deploy == nil || reservations == nil || reservations.MemoryBytes == 0 {
   244  			warnf("missing memory reservation; specify deploy.resources.reservations.memory to avoid out-of-memory errors")
   245  		}
   246  
   247  		if dnsRoleVal := svccfg.Extensions["x-defang-dns-role"]; dnsRoleVal != nil {
   248  			if _, ok := dnsRoleVal.(string); !ok {
   249  				return fmt.Errorf("x-defang-dns-role must be a string")
   250  			}
   251  		}
   252  
   253  		if staticFilesVal := svccfg.Extensions["x-defang-static-files"]; staticFilesVal != nil {
   254  			if _, ok := staticFilesVal.(string); !ok {
   255  				return fmt.Errorf("x-defang-static-files must be a string")
   256  			}
   257  		}
   258  	}
   259  	return nil
   260  }