github.com/vshn/k8ify@v1.1.2-0.20240502214202-6c9ed3ef0bf4/pkg/converter/converter.go (about)

     1  package converter
     2  
     3  import (
     4  	"fmt"
     5  	v1 "k8s.io/api/policy/v1"
     6  	"log"
     7  	"maps"
     8  	"os"
     9  	"os/exec"
    10  	"sort"
    11  	"strconv"
    12  	"strings"
    13  
    14  	composeTypes "github.com/compose-spec/compose-go/types"
    15  	"github.com/sirupsen/logrus"
    16  	"github.com/vshn/k8ify/pkg/ir"
    17  	"github.com/vshn/k8ify/pkg/util"
    18  	apps "k8s.io/api/apps/v1"
    19  	core "k8s.io/api/core/v1"
    20  	networking "k8s.io/api/networking/v1"
    21  	"k8s.io/apimachinery/pkg/api/resource"
    22  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    23  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    24  	"k8s.io/apimachinery/pkg/util/intstr"
    25  	"k8s.io/utils/ptr"
    26  	"sigs.k8s.io/yaml"
    27  )
    28  
    29  var (
    30  	SecretRefMagic = "ylkBUFN0o29yr4yLCTUZqzgIT6qCIbyj" // magic string to indicate that what follows isn't a value but a reference to a secret
    31  )
    32  
    33  func composeServiceVolumesToK8s(
    34  	refSlug string,
    35  	mounts []composeTypes.ServiceVolumeConfig,
    36  	projectVolumes map[string]*ir.Volume,
    37  ) (map[string]core.Volume, []core.VolumeMount) {
    38  
    39  	volumeMounts := []core.VolumeMount{}
    40  	volumes := make(map[string]core.Volume)
    41  
    42  	for _, mount := range mounts {
    43  		if mount.Type != "volume" {
    44  			continue
    45  		}
    46  		name := util.Sanitize(mount.Source)
    47  
    48  		volumeMounts = append(volumeMounts, core.VolumeMount{
    49  			MountPath: mount.Target,
    50  			Name:      name,
    51  		})
    52  
    53  		volume := projectVolumes[mount.Source]
    54  		if volume.IsShared() {
    55  			volumes[name] = core.Volume{
    56  				Name: name,
    57  				VolumeSource: core.VolumeSource{
    58  					PersistentVolumeClaim: &core.PersistentVolumeClaimVolumeSource{
    59  						ClaimName: mount.Source + refSlug,
    60  					},
    61  				},
    62  			}
    63  		}
    64  	}
    65  	return volumes, volumeMounts
    66  }
    67  
    68  func composeServicePortsToK8sServicePorts(workload *ir.Service) []core.ServicePort {
    69  	servicePorts := []core.ServicePort{}
    70  	ports := workload.GetPorts()
    71  	// the single k8s service contains the ports of all parts
    72  	for _, part := range workload.GetParts() {
    73  		ports = append(ports, part.GetPorts()...)
    74  	}
    75  	for _, port := range ports {
    76  		servicePorts = append(servicePorts, core.ServicePort{
    77  			Name: fmt.Sprint(port.ServicePort),
    78  			Port: int32(port.ServicePort),
    79  			TargetPort: intstr.IntOrString{
    80  				IntVal: int32(port.ContainerPort),
    81  			},
    82  		})
    83  	}
    84  	return servicePorts
    85  }
    86  
    87  func composeServicePortsToK8sContainerPorts(workload *ir.Service) []core.ContainerPort {
    88  	containerPorts := []core.ContainerPort{}
    89  	for _, port := range workload.AsCompose().Ports {
    90  		containerPorts = append(containerPorts, core.ContainerPort{
    91  			ContainerPort: int32(port.Target),
    92  		})
    93  	}
    94  	return containerPorts
    95  }
    96  
    97  func composeServiceToSecret(workload *ir.Service, refSlug string, labels map[string]string) *core.Secret {
    98  	stringData := make(map[string]string)
    99  	for key, value := range workload.AsCompose().Environment {
   100  		if value != nil && strings.HasPrefix(*value, SecretRefMagic+":") {
   101  			// we've encountered a reference to another secret (starting with "$_ref_:" in the compose file), ignore
   102  			continue
   103  		}
   104  		if value == nil {
   105  			stringData[key] = ""
   106  		} else {
   107  			stringData[key] = *value
   108  		}
   109  	}
   110  	if len(stringData) == 0 {
   111  		return nil
   112  	}
   113  	secret := core.Secret{}
   114  	secret.APIVersion = "v1"
   115  	secret.Kind = "Secret"
   116  	secret.Name = workload.Name + refSlug + "-env"
   117  	secret.Labels = labels
   118  	secret.Annotations = util.Annotations(workload.Labels(), "Secret")
   119  	secret.StringData = stringData
   120  	return &secret
   121  }
   122  
   123  func composeServiceToDeployment(
   124  	workload *ir.Service,
   125  	refSlug string,
   126  	projectVolumes map[string]*ir.Volume,
   127  	labels map[string]string,
   128  ) (apps.Deployment, []core.Secret) {
   129  
   130  	deployment := apps.Deployment{}
   131  	deployment.APIVersion = "apps/v1"
   132  	deployment.Kind = "Deployment"
   133  	deployment.Name = workload.AsCompose().Name + refSlug
   134  	deployment.Labels = labels
   135  	deployment.Annotations = util.Annotations(workload.Labels(), "Deployment")
   136  
   137  	templateSpec, secrets := composeServiceToPodTemplate(
   138  		workload,
   139  		refSlug,
   140  		projectVolumes,
   141  		labels,
   142  		util.ServiceAccountName(workload.AsCompose().Labels),
   143  	)
   144  
   145  	deployment.Spec = apps.DeploymentSpec{
   146  		Replicas: composeServiceToReplicas(workload.AsCompose()),
   147  		Strategy: composeServiceToStrategy(workload.AsCompose()),
   148  		Template: templateSpec,
   149  		Selector: &metav1.LabelSelector{
   150  			MatchLabels: labels,
   151  		},
   152  	}
   153  
   154  	return deployment, secrets
   155  }
   156  
   157  func composeServiceToStrategy(composeService composeTypes.ServiceConfig) apps.DeploymentStrategy {
   158  	order := getUpdateOrder(composeService)
   159  	var typ apps.DeploymentStrategyType
   160  
   161  	switch order {
   162  	case "start-first":
   163  		typ = apps.RollingUpdateDeploymentStrategyType
   164  	default:
   165  		typ = apps.RecreateDeploymentStrategyType
   166  	}
   167  
   168  	return apps.DeploymentStrategy{
   169  		Type: typ,
   170  	}
   171  }
   172  
   173  func getUpdateOrder(composeService composeTypes.ServiceConfig) string {
   174  	if composeService.Deploy == nil || composeService.Deploy.UpdateConfig == nil {
   175  		return "stop-first"
   176  	}
   177  	return composeService.Deploy.UpdateConfig.Order
   178  }
   179  
   180  func composeServiceToStatefulSet(
   181  	workload *ir.Service,
   182  	refSlug string,
   183  	projectVolumes map[string]*ir.Volume,
   184  	volumeClaims []core.PersistentVolumeClaim,
   185  	labels map[string]string,
   186  ) (apps.StatefulSet, []core.Secret) {
   187  	statefulset := apps.StatefulSet{}
   188  	statefulset.APIVersion = "apps/v1"
   189  	statefulset.Kind = "StatefulSet"
   190  	statefulset.Name = workload.AsCompose().Name + refSlug
   191  	statefulset.Labels = labels
   192  	statefulset.Annotations = util.Annotations(workload.Labels(), "StatefulSet")
   193  
   194  	templateSpec, secrets := composeServiceToPodTemplate(
   195  		workload,
   196  		refSlug,
   197  		projectVolumes,
   198  		labels,
   199  		util.ServiceAccountName(workload.AsCompose().Labels),
   200  	)
   201  
   202  	statefulset.Spec = apps.StatefulSetSpec{
   203  		Replicas: composeServiceToReplicas(workload.AsCompose()),
   204  		Template: templateSpec,
   205  		Selector: &metav1.LabelSelector{
   206  			MatchLabels: labels,
   207  		},
   208  		VolumeClaimTemplates: volumeClaims,
   209  	}
   210  
   211  	return statefulset, secrets
   212  }
   213  
   214  func composeServiceToReplicas(composeService composeTypes.ServiceConfig) *int32 {
   215  	deploy := composeService.Deploy
   216  	if deploy == nil || deploy.Replicas == nil {
   217  		return nil
   218  	}
   219  	// deploy.Replicas is an Uint64, but if you have over 2'000'000'000
   220  	// replicas, you might have different problems :)
   221  	return ptr.To(int32(*deploy.Replicas))
   222  }
   223  
   224  func composeServiceToPodTemplate(
   225  	workload *ir.Service,
   226  	refSlug string,
   227  	projectVolumes map[string]*ir.Volume,
   228  	labels map[string]string,
   229  	serviceAccountName string,
   230  ) (core.PodTemplateSpec, []core.Secret) {
   231  	container, secret, volumes := composeServiceToContainer(workload, refSlug, projectVolumes, labels)
   232  	containers := []core.Container{container}
   233  	secrets := []core.Secret{}
   234  	if secret != nil {
   235  		secrets = append(secrets, *secret)
   236  	}
   237  
   238  	for _, part := range workload.GetParts() {
   239  		c, s, cvs := composeServiceToContainer(part, refSlug, projectVolumes, labels)
   240  		containers = append(containers, c)
   241  		if s != nil {
   242  			secrets = append(secrets, *s)
   243  		}
   244  		maps.Copy(volumes, cvs)
   245  	}
   246  
   247  	// make sure the array is sorted to have deterministic output
   248  	keys := make([]string, 0, len(volumes))
   249  	for key := range volumes {
   250  		keys = append(keys, key)
   251  	}
   252  	sort.Strings(keys)
   253  	volumesArray := []core.Volume{}
   254  	for _, key := range keys {
   255  		volumesArray = append(volumesArray, volumes[key])
   256  	}
   257  
   258  	podSpec := core.PodSpec{
   259  		Containers:         containers,
   260  		RestartPolicy:      core.RestartPolicyAlways,
   261  		Volumes:            volumesArray,
   262  		ServiceAccountName: serviceAccountName,
   263  		Affinity:           composeServiceToAffinity(workload),
   264  	}
   265  
   266  	return core.PodTemplateSpec{
   267  		Spec: podSpec,
   268  		ObjectMeta: metav1.ObjectMeta{
   269  			Labels:      labels,
   270  			Annotations: util.Annotations(workload.Labels(), "Pod"),
   271  		},
   272  	}, secrets
   273  }
   274  
   275  func composeServiceToAffinity(workload *ir.Service) *core.Affinity {
   276  	podAntiAffinity := core.PodAntiAffinity{
   277  		RequiredDuringSchedulingIgnoredDuringExecution: []core.PodAffinityTerm{
   278  			{
   279  				LabelSelector: &metav1.LabelSelector{
   280  					MatchExpressions: []metav1.LabelSelectorRequirement{
   281  						{
   282  							Key:      "k8ify.service",
   283  							Operator: "In",
   284  							Values:   []string{workload.Name},
   285  						},
   286  					},
   287  				},
   288  				TopologyKey: "kubernetes.io/hostname", // should be available on pretty much any k8s setup
   289  			},
   290  		},
   291  	}
   292  	affinity := core.Affinity{
   293  		PodAntiAffinity: &podAntiAffinity,
   294  	}
   295  	return &affinity
   296  }
   297  
   298  func composeServiceToContainer(
   299  	workload *ir.Service,
   300  	refSlug string,
   301  	projectVolumes map[string]*ir.Volume,
   302  	labels map[string]string,
   303  ) (core.Container, *core.Secret, map[string]core.Volume) {
   304  	composeService := workload.AsCompose()
   305  	volumes, volumeMounts := composeServiceVolumesToK8s(
   306  		refSlug, workload.AsCompose().Volumes, projectVolumes,
   307  	)
   308  	livenessProbe, readinessProbe, startupProbe := composeServiceToProbes(workload)
   309  	containerPorts := composeServicePortsToK8sContainerPorts(workload)
   310  	resources := composeServiceToResourceRequirements(composeService)
   311  	secret := composeServiceToSecret(workload, refSlug, labels)
   312  	envFrom := []core.EnvFromSource{}
   313  	if secret != nil {
   314  		envFrom = append(envFrom, core.EnvFromSource{SecretRef: &core.SecretEnvSource{LocalObjectReference: core.LocalObjectReference{Name: secret.Name}}})
   315  	}
   316  	env := []core.EnvVar{}
   317  	for key, value := range workload.AsCompose().Environment {
   318  		if value != nil && strings.HasPrefix(*value, SecretRefMagic+":") {
   319  			// we've encountered a reference to another secret (starting with "$_ref_:" in the compose file)
   320  			refValue := (*value)[len(SecretRefMagic)+1:]
   321  			refStrings := strings.SplitN(refValue, ":", 2)
   322  			if len(refStrings) != 2 {
   323  				logrus.Warnf("Secret reference '$_ref_:%s' has invalid format, should be '$_ref_:SECRETNAME:KEY'. Ignoring.", refValue)
   324  				continue
   325  			}
   326  			env = append(env, core.EnvVar{Name: key, ValueFrom: &core.EnvVarSource{SecretKeyRef: &core.SecretKeySelector{LocalObjectReference: core.LocalObjectReference{Name: refStrings[0]}, Key: refStrings[1]}}})
   327  		}
   328  	}
   329  	return core.Container{
   330  		Name:  composeService.Name + refSlug,
   331  		Image: composeService.Image,
   332  		Ports: containerPorts,
   333  		// We COULD put the environment variables here, but because some of them likely contain sensitive data they are stored in a secret instead
   334  		// Env:          envVars,
   335  		// Reference the secret:
   336  		EnvFrom:         envFrom,
   337  		Env:             env,
   338  		VolumeMounts:    volumeMounts,
   339  		LivenessProbe:   livenessProbe,
   340  		ReadinessProbe:  readinessProbe,
   341  		StartupProbe:    startupProbe,
   342  		Resources:       resources,
   343  		Command:         composeService.Entrypoint, // ENTRYPOINT in Docker == 'entrypoint' in Compose == 'command' in K8s
   344  		Args:            composeService.Command,    // CMD in Docker == 'command' in Compose == 'args' in K8s
   345  		ImagePullPolicy: core.PullAlways,
   346  	}, secret, volumes
   347  }
   348  
   349  func serviceSpecToService(refSlug string, workload *ir.Service, serviceSpec core.ServiceSpec, labels map[string]string) core.Service {
   350  	serviceName := workload.Name + refSlug
   351  	// We only add the port numbers to the service name if the ports are exposed directly. This is to ensure backwards compatibility with previous versions of k8ify and to keep things neat (not many people will need to expose ports directly).
   352  	if !serviceSpecIsUnexposedDefault(serviceSpec) {
   353  		for _, port := range serviceSpec.Ports {
   354  			serviceName = fmt.Sprintf("%s-%d", serviceName, port.Port)
   355  		}
   356  	}
   357  	service := core.Service{}
   358  	service.Spec = serviceSpec
   359  	service.APIVersion = "v1"
   360  	service.Kind = "Service"
   361  	service.Name = serviceName
   362  	service.Labels = labels
   363  	service.Annotations = util.Annotations(workload.Labels(), "Service")
   364  	return service
   365  }
   366  
   367  // PortConfig only exists to be used as a map key (we can't use core.ServiceSpec)
   368  type PortConfig struct {
   369  	Type                  core.ServiceType
   370  	ExternalTrafficPolicy core.ServiceExternalTrafficPolicy
   371  	HealthCheckNodePort   int32
   372  }
   373  
   374  func serviceSpecIsUnexposedDefault(serviceSpec core.ServiceSpec) bool {
   375  	return serviceSpec.Type == "" && serviceSpec.ExternalTrafficPolicy == "" && serviceSpec.HealthCheckNodePort == 0
   376  }
   377  
   378  func composeServiceToServices(refSlug string, workload *ir.Service, servicePorts []core.ServicePort, labels map[string]string) []core.Service {
   379  	var services []core.Service
   380  	serviceSpecs := map[PortConfig]core.ServiceSpec{}
   381  
   382  	for _, servicePort := range servicePorts {
   383  		portConfig := PortConfig{
   384  			Type:                  util.ServiceType(workload.Labels(), servicePort.Port),
   385  			ExternalTrafficPolicy: util.ServiceExternalTrafficPolicy(workload.Labels(), servicePort.Port),
   386  			HealthCheckNodePort:   util.ServiceHealthCheckNodePort(workload.Labels(), servicePort.Port),
   387  		}
   388  		spec, specExists := serviceSpecs[portConfig]
   389  		if specExists {
   390  			spec.Ports = append(serviceSpecs[portConfig].Ports, servicePort)
   391  			serviceSpecs[portConfig] = spec
   392  		} else {
   393  			serviceSpecs[portConfig] = core.ServiceSpec{
   394  				Selector:              labels,
   395  				Type:                  portConfig.Type,
   396  				ExternalTrafficPolicy: portConfig.ExternalTrafficPolicy,
   397  				HealthCheckNodePort:   portConfig.HealthCheckNodePort,
   398  				Ports:                 []core.ServicePort{servicePort},
   399  			}
   400  		}
   401  	}
   402  
   403  	for _, serviceSpec := range serviceSpecs {
   404  		services = append(services, serviceSpecToService(refSlug, workload, serviceSpec, labels))
   405  	}
   406  
   407  	return services
   408  }
   409  
   410  func composeServiceToIngress(workload *ir.Service, refSlug string, services []core.Service, labels map[string]string, targetCfg ir.TargetCfg) *networking.Ingress {
   411  	var service *core.Service
   412  	for _, s := range services {
   413  		if serviceSpecIsUnexposedDefault(s.Spec) {
   414  			service = &s // This only works since Go 1.22
   415  		}
   416  	}
   417  	if service == nil {
   418  		return nil
   419  	}
   420  
   421  	workloads := []*ir.Service{workload}
   422  	workloads = append(workloads, workload.GetParts()...)
   423  
   424  	var ingressRules []networking.IngressRule
   425  	var ingressTLSs []networking.IngressTLS
   426  
   427  	for _, w := range workloads {
   428  		for i, port := range w.GetPorts() {
   429  			// we expect the config to be in "k8ify.expose.PORT"
   430  			configPrefix := fmt.Sprintf("k8ify.expose.%d", port.ServicePort)
   431  			ingressConfig := util.SubConfig(w.Labels(), configPrefix, "host")
   432  			if _, ok := ingressConfig["host"]; !ok && i == 0 {
   433  				// for the first port we also accept config in "k8ify.expose"
   434  				ingressConfig = util.SubConfig(w.Labels(), "k8ify.expose", "host")
   435  			}
   436  
   437  			if host, ok := ingressConfig["host"]; ok {
   438  				serviceBackendPort := networking.ServiceBackendPort{
   439  					Number: int32(port.ServicePort),
   440  				}
   441  
   442  				ingressServiceBackend := networking.IngressServiceBackend{
   443  					Name: service.Name,
   444  					Port: serviceBackendPort,
   445  				}
   446  
   447  				ingressBackend := networking.IngressBackend{
   448  					Service: &ingressServiceBackend,
   449  				}
   450  
   451  				pathType := networking.PathTypePrefix
   452  				path := networking.HTTPIngressPath{
   453  					PathType: &pathType,
   454  					Path:     "/",
   455  					Backend:  ingressBackend,
   456  				}
   457  
   458  				httpIngressRuleValue := networking.HTTPIngressRuleValue{
   459  					Paths: []networking.HTTPIngressPath{path},
   460  				}
   461  
   462  				ingressRuleValue := networking.IngressRuleValue{
   463  					HTTP: &httpIngressRuleValue,
   464  				}
   465  
   466  				ingressRules = append(ingressRules, networking.IngressRule{
   467  					Host:             host,
   468  					IngressRuleValue: ingressRuleValue,
   469  				})
   470  
   471  				if targetCfg.IsSubdomainOfAppsDomain(host) {
   472  					// special case: With an empty TLS configuration the ingress uses the cluster-wide apps domain wildcard certificate
   473  					ingressTLSs = append(ingressTLSs, networking.IngressTLS{})
   474  				} else {
   475  					ingressTLSs = append(ingressTLSs, networking.IngressTLS{
   476  						Hosts:      []string{host},
   477  						SecretName: workload.Name + refSlug,
   478  					})
   479  				}
   480  			}
   481  		}
   482  	}
   483  
   484  	if len(ingressRules) == 0 {
   485  		return nil
   486  	}
   487  
   488  	ingress := networking.Ingress{}
   489  	ingress.APIVersion = "networking.k8s.io/v1"
   490  	ingress.Kind = "Ingress"
   491  	ingress.Name = workload.Name + refSlug
   492  	ingress.Labels = labels
   493  	ingress.Annotations = util.Annotations(workload.Labels(), "Ingress")
   494  	ingress.Spec = networking.IngressSpec{
   495  		Rules: ingressRules,
   496  		TLS:   ingressTLSs,
   497  	}
   498  
   499  	return &ingress
   500  }
   501  
   502  func composeServiceToProbe(config map[string]string, port intstr.IntOrString) *core.Probe {
   503  	if enabledStr, ok := config["enabled"]; ok {
   504  		if !util.IsTruthy(enabledStr) {
   505  			return nil
   506  		}
   507  	}
   508  
   509  	path := ""
   510  	if pathStr, ok := config["path"]; ok {
   511  		path = pathStr
   512  	}
   513  
   514  	scheme := core.URISchemeHTTP
   515  	if schemeStr, ok := config["scheme"]; ok {
   516  		if schemeStr == "HTTPS" || schemeStr == "https" {
   517  			scheme = core.URISchemeHTTPS
   518  		}
   519  	}
   520  
   521  	periodSeconds := util.ConfigGetInt32(config, "periodSeconds", 30)
   522  	timeoutSeconds := util.ConfigGetInt32(config, "timeoutSeconds", 60)
   523  	initialDelaySeconds := util.ConfigGetInt32(config, "initialDelaySeconds", 0)
   524  	successThreshold := util.ConfigGetInt32(config, "successThreshold", 1)
   525  	failureThreshold := util.ConfigGetInt32(config, "failureThreshold", 3)
   526  
   527  	probeHandler := core.ProbeHandler{}
   528  	if path == "" {
   529  		probeHandler.TCPSocket = &core.TCPSocketAction{
   530  			Port: port,
   531  		}
   532  	} else {
   533  		probeHandler.HTTPGet = &core.HTTPGetAction{
   534  			Path:   path,
   535  			Port:   port,
   536  			Scheme: scheme,
   537  		}
   538  	}
   539  
   540  	return &core.Probe{
   541  		ProbeHandler:        probeHandler,
   542  		PeriodSeconds:       periodSeconds,
   543  		TimeoutSeconds:      timeoutSeconds,
   544  		InitialDelaySeconds: initialDelaySeconds,
   545  		SuccessThreshold:    successThreshold,
   546  		FailureThreshold:    failureThreshold,
   547  	}
   548  }
   549  
   550  func composeServiceToProbes(workload *ir.Service) (*core.Probe, *core.Probe, *core.Probe) {
   551  	composeService := workload.AsCompose()
   552  	if len(composeService.Ports) == 0 {
   553  		return nil, nil, nil
   554  	}
   555  	port := intstr.IntOrString{IntVal: int32(composeService.Ports[0].Target)}
   556  	livenessConfig := util.SubConfig(composeService.Labels, "k8ify.liveness", "path")
   557  	readinessConfig := util.SubConfig(composeService.Labels, "k8ify.readiness", "path")
   558  	startupConfig := util.SubConfig(composeService.Labels, "k8ify.startup", "path")
   559  
   560  	// Protect application from overly eager livenessProbe during startup while keeping the startup fast.
   561  	// By default the startupProbe is the same as the livenessProbe except for periodSeconds and failureThreshold
   562  	for k, v := range livenessConfig {
   563  		if _, ok := startupConfig[k]; !ok {
   564  			startupConfig[k] = v
   565  		}
   566  	}
   567  	if _, ok := startupConfig["periodSeconds"]; !ok {
   568  		startupConfig["periodSeconds"] = "10"
   569  	}
   570  	if _, ok := startupConfig["failureThreshold"]; !ok {
   571  		startupConfig["failureThreshold"] = "30" // will try for a total of 300s
   572  	}
   573  
   574  	// By default the readinessProbe is disabled.
   575  	if len(readinessConfig) == 0 {
   576  		readinessConfig["enabled"] = "false"
   577  	}
   578  
   579  	livenessProbe := composeServiceToProbe(livenessConfig, port)
   580  	readinessProbe := composeServiceToProbe(readinessConfig, port)
   581  	startupProbe := composeServiceToProbe(startupConfig, port)
   582  	return livenessProbe, readinessProbe, startupProbe
   583  }
   584  
   585  func composeServiceToResourceRequirements(composeService composeTypes.ServiceConfig) core.ResourceRequirements {
   586  	requestsMap := core.ResourceList{}
   587  	limitsMap := core.ResourceList{}
   588  
   589  	if composeService.Deploy != nil {
   590  		if composeService.Deploy.Resources.Reservations != nil {
   591  			// NanoCPU appears to be a misnomer, it's actually a float indicating the number of CPU cores, nothing 'nano'
   592  			cpuRequest, err := strconv.ParseFloat(composeService.Deploy.Resources.Reservations.NanoCPUs, 64)
   593  			if err == nil && cpuRequest > 0 {
   594  				requestsMap["cpu"] = resource.MustParse(fmt.Sprintf("%f", cpuRequest))
   595  				limitsMap["cpu"] = resource.MustParse(fmt.Sprintf("%f", cpuRequest*10.0))
   596  			}
   597  			memRequest := composeService.Deploy.Resources.Reservations.MemoryBytes
   598  			if memRequest > 0 {
   599  				requestsMap["memory"] = resource.MustParse(fmt.Sprintf("%dMi", memRequest/1024/1024))
   600  				limitsMap["memory"] = resource.MustParse(fmt.Sprintf("%dMi", memRequest/1024/1024))
   601  			}
   602  		}
   603  		if composeService.Deploy.Resources.Limits != nil {
   604  			// If there are explicit limits configured we ignore the defaults calculated from the requests
   605  			limitsMap = core.ResourceList{}
   606  			cpuLimit, err := strconv.ParseFloat(composeService.Deploy.Resources.Limits.NanoCPUs, 64)
   607  			if err == nil && cpuLimit > 0 {
   608  				limitsMap["cpu"] = resource.MustParse(fmt.Sprintf("%f", cpuLimit))
   609  			}
   610  			memLimit := composeService.Deploy.Resources.Limits.MemoryBytes
   611  			if memLimit > 0 {
   612  				limitsMap["memory"] = resource.MustParse(fmt.Sprintf("%dMi", memLimit/1024/1024))
   613  			}
   614  		}
   615  	}
   616  
   617  	resources := core.ResourceRequirements{
   618  		Requests: requestsMap,
   619  		Limits:   limitsMap,
   620  	}
   621  	return resources
   622  }
   623  
   624  func toRefSlug(ref string, resource WithLabels) string {
   625  	if ref == "" || util.IsSingleton(resource.Labels()) {
   626  		return ""
   627  	}
   628  
   629  	return ref
   630  }
   631  
   632  type WithLabels interface {
   633  	Labels() map[string]string
   634  }
   635  
   636  func CallExternalConverter(resourceName string, options map[string]string) (unstructured.Unstructured, error) {
   637  	args := []string{resourceName}
   638  	for k, v := range options {
   639  		if k != "cmd" {
   640  			args = append(args, "--"+k, v)
   641  		}
   642  	}
   643  	cmd := exec.Command(options["cmd"], args...)
   644  	cmd.Stderr = os.Stderr
   645  	output, err := cmd.Output()
   646  	if err != nil {
   647  		for _, line := range strings.Split(string(output), "\n") {
   648  			logrus.Error(line)
   649  		}
   650  		return unstructured.Unstructured{}, fmt.Errorf("Could not convert service '%s' using command '%s': %w", resourceName, options["cmd"], err)
   651  	}
   652  	otherResource := unstructured.Unstructured{}
   653  	err = yaml.Unmarshal(output, &otherResource)
   654  	if err != nil {
   655  		return unstructured.Unstructured{}, fmt.Errorf("Could not convert service '%s' using command '%s': %w", resourceName, options["cmd"], err)
   656  	}
   657  	return otherResource, nil
   658  }
   659  
   660  func ComposeServiceToK8s(ref string, workload *ir.Service, projectVolumes map[string]*ir.Volume, targetCfg ir.TargetCfg) Objects {
   661  	refSlug := toRefSlug(util.SanitizeWithMinLength(ref, 4), workload)
   662  	labels := make(map[string]string)
   663  	labels["k8ify.service"] = workload.Name
   664  	if refSlug != "" {
   665  		labels["k8ify.ref-slug"] = refSlug
   666  		refSlug = "-" + refSlug
   667  	}
   668  
   669  	objects := Objects{}
   670  
   671  	if util.Converter(workload.Labels()) != nil {
   672  		otherResource, err := CallExternalConverter(workload.Name+refSlug, util.SubConfig(workload.Labels(), "k8ify.converter", "cmd"))
   673  		if err != nil {
   674  			log.Fatal(err)
   675  		}
   676  		if otherResource.GetLabels() == nil {
   677  			otherResource.SetLabels(labels)
   678  		} else {
   679  			for k, v := range labels {
   680  				otherResource.GetLabels()[k] = v
   681  			}
   682  		}
   683  		annotations := util.Annotations(workload.Labels(), otherResource.GetKind())
   684  		if otherResource.GetAnnotations() == nil {
   685  			otherResource.SetAnnotations(annotations)
   686  		} else {
   687  			for k, v := range annotations {
   688  				otherResource.GetAnnotations()[k] = v
   689  			}
   690  		}
   691  		objects.Others = append([]unstructured.Unstructured{}, otherResource)
   692  		return objects
   693  	}
   694  
   695  	servicePorts := composeServicePortsToK8sServicePorts(workload)
   696  	objects.Services = composeServiceToServices(refSlug, workload, servicePorts, labels)
   697  
   698  	// Find volumes used by this service and all its parts
   699  	rwoVolumes, rwxVolumes := workload.Volumes(projectVolumes)
   700  	for _, part := range workload.GetParts() {
   701  		rwoV, rwxV := part.Volumes(projectVolumes)
   702  		maps.Copy(rwoVolumes, rwoV)
   703  		maps.Copy(rwxVolumes, rwxV)
   704  	}
   705  
   706  	// All shared (rwx) volumes used by the service, no matter if the service is a StatefulSet or a Deployment, must be
   707  	// turned into PersistentVolumeClaims. Note that since these volumes are shared, the same PersistentVolumeClaim might
   708  	// be generated by multiple compose services. Objects.Append() takes care of deduplication.
   709  	pvcs := []core.PersistentVolumeClaim{}
   710  	for _, vol := range rwxVolumes {
   711  		pvcs = append(pvcs, ComposeSharedVolumeToK8s(ref, vol))
   712  	}
   713  	objects.PersistentVolumeClaims = pvcs
   714  
   715  	if len(rwoVolumes) > 0 {
   716  		// rwo volumes mean that we can only have one instance of the service, hence StatefulSet is the right choice.
   717  		// Technically we might have multiple instances with a StatefulSet but then every instance gets its own volume,
   718  		// ensuring that each volume remains rwo
   719  		pvcs := []core.PersistentVolumeClaim{}
   720  		for _, vol := range rwoVolumes {
   721  			pvcs = append(pvcs, composeVolumeToPvc(vol.Name, labels, vol))
   722  		}
   723  
   724  		statefulset, secrets := composeServiceToStatefulSet(
   725  			workload,
   726  			refSlug,
   727  			projectVolumes,
   728  			pvcs,
   729  			labels,
   730  		)
   731  		objects.StatefulSets = []apps.StatefulSet{statefulset}
   732  		objects.Secrets = secrets
   733  	} else {
   734  		deployment, secrets := composeServiceToDeployment(
   735  			workload,
   736  			refSlug,
   737  			projectVolumes,
   738  			labels,
   739  		)
   740  		objects.Deployments = []apps.Deployment{deployment}
   741  		objects.Secrets = secrets
   742  	}
   743  
   744  	podDisruptionBudget := composeServiceToPodDisruptionBudget(workload, refSlug, labels)
   745  	if podDisruptionBudget == nil {
   746  		objects.PodDisruptionBudgets = []v1.PodDisruptionBudget{}
   747  	} else {
   748  		objects.PodDisruptionBudgets = []v1.PodDisruptionBudget{*podDisruptionBudget}
   749  	}
   750  
   751  	ingress := composeServiceToIngress(workload, refSlug, objects.Services, labels, targetCfg)
   752  	if ingress == nil {
   753  		objects.Ingresses = []networking.Ingress{}
   754  	} else {
   755  		objects.Ingresses = []networking.Ingress{*ingress}
   756  	}
   757  
   758  	return objects
   759  }
   760  
   761  func ComposeSharedVolumeToK8s(ref string, volume *ir.Volume) core.PersistentVolumeClaim {
   762  	refSlug := toRefSlug(util.SanitizeWithMinLength(ref, 4), volume)
   763  	labels := make(map[string]string)
   764  	labels["k8ify.volume"] = volume.Name
   765  	if refSlug != "" {
   766  		labels["k8ify.ref-slug"] = refSlug
   767  		refSlug = "-" + refSlug
   768  	}
   769  	name := volume.Name + refSlug
   770  	pvc := composeVolumeToPvc(name, labels, volume)
   771  
   772  	return pvc
   773  }
   774  
   775  func composeVolumeToPvc(name string, labels map[string]string, volume *ir.Volume) core.PersistentVolumeClaim {
   776  	name = util.Sanitize(name)
   777  	accessMode := core.ReadWriteOnce
   778  	if volume.IsShared() {
   779  		accessMode = core.ReadWriteMany
   780  	}
   781  
   782  	return core.PersistentVolumeClaim{
   783  		TypeMeta: metav1.TypeMeta{
   784  			APIVersion: "v1",
   785  			Kind:       "PersistentVolumeClaim",
   786  		},
   787  		ObjectMeta: metav1.ObjectMeta{
   788  			Name:   name,
   789  			Labels: labels,
   790  		},
   791  		Spec: core.PersistentVolumeClaimSpec{
   792  			AccessModes: []core.PersistentVolumeAccessMode{accessMode},
   793  			Resources: core.ResourceRequirements{
   794  				Requests: core.ResourceList{
   795  					"storage": volume.Size("1G"),
   796  				},
   797  			},
   798  			StorageClassName: util.StorageClass(volume.Labels()),
   799  		},
   800  	}
   801  }
   802  
   803  func composeServiceToPodDisruptionBudget(workload *ir.Service, refSlug string, labels map[string]string) *v1.PodDisruptionBudget {
   804  	replicas := composeServiceToReplicas(workload.AsCompose())
   805  	if replicas == nil || *replicas <= 1 {
   806  		return nil
   807  	}
   808  
   809  	podDisruptionBudget := v1.PodDisruptionBudget{}
   810  	podDisruptionBudget.APIVersion = "policy/v1"
   811  	podDisruptionBudget.Kind = "PodDisruptionBudget"
   812  	podDisruptionBudget.Name = workload.Name + refSlug
   813  	podDisruptionBudget.Labels = labels
   814  	podDisruptionBudget.Annotations = util.Annotations(workload.Labels(), podDisruptionBudget.Kind)
   815  	maxUnavailable := intstr.FromString("50%")
   816  	podDisruptionBudget.Spec = v1.PodDisruptionBudgetSpec{
   817  		MaxUnavailable: &maxUnavailable,
   818  		Selector: &metav1.LabelSelector{
   819  			MatchLabels: labels,
   820  		},
   821  	}
   822  	return &podDisruptionBudget
   823  }
   824  
   825  // Objects combines all possible resources the conversion process could produce
   826  type Objects struct {
   827  	// Deployments
   828  	Deployments            []apps.Deployment
   829  	StatefulSets           []apps.StatefulSet
   830  	Services               []core.Service
   831  	PersistentVolumeClaims []core.PersistentVolumeClaim
   832  	Secrets                []core.Secret
   833  	Ingresses              []networking.Ingress
   834  	PodDisruptionBudgets   []v1.PodDisruptionBudget
   835  	Others                 []unstructured.Unstructured
   836  }
   837  
   838  func (this Objects) Append(other Objects) Objects {
   839  	// Merge PVCs while avoiding duplicates based on the name
   840  	pvcs := this.PersistentVolumeClaims
   841  	nameSet := make(map[string]bool)
   842  	for _, pvc := range pvcs {
   843  		nameSet[pvc.Name] = true
   844  	}
   845  	for _, pvc := range other.PersistentVolumeClaims {
   846  		if !nameSet[pvc.Name] {
   847  			pvcs = append(pvcs, pvc)
   848  			nameSet[pvc.Name] = true
   849  		}
   850  	}
   851  
   852  	return Objects{
   853  		Deployments:            append(this.Deployments, other.Deployments...),
   854  		StatefulSets:           append(this.StatefulSets, other.StatefulSets...),
   855  		Services:               append(this.Services, other.Services...),
   856  		PersistentVolumeClaims: pvcs,
   857  		Secrets:                append(this.Secrets, other.Secrets...),
   858  		Ingresses:              append(this.Ingresses, other.Ingresses...),
   859  		PodDisruptionBudgets:   append(this.PodDisruptionBudgets, other.PodDisruptionBudgets...),
   860  		Others:                 append(this.Others, other.Others...),
   861  	}
   862  }