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 }