github.com/khulnasoft/cli@v0.0.0-20240402070845-01bcad7beefa/cli/compose/convert/service.go (about) 1 package convert 2 3 import ( 4 "context" 5 "os" 6 "sort" 7 "strings" 8 "time" 9 10 "github.com/docker/docker/api/types/container" 11 "github.com/docker/docker/api/types/swarm" 12 "github.com/docker/docker/api/types/versions" 13 "github.com/docker/docker/client" 14 "github.com/khulnasoft-lab/go-units" 15 servicecli "github.com/khulnasoft/cli/cli/command/service" 16 composetypes "github.com/khulnasoft/cli/cli/compose/types" 17 "github.com/khulnasoft/cli/opts" 18 "github.com/pkg/errors" 19 ) 20 21 const ( 22 defaultNetwork = "default" 23 // LabelImage is the label used to store image name provided in the compose file 24 LabelImage = "com.docker.stack.image" 25 ) 26 27 // Services from compose-file types to engine API types 28 func Services( 29 ctx context.Context, 30 namespace Namespace, 31 config *composetypes.Config, 32 apiClient client.CommonAPIClient, 33 ) (map[string]swarm.ServiceSpec, error) { 34 result := make(map[string]swarm.ServiceSpec) 35 for _, service := range config.Services { 36 secrets, err := convertServiceSecrets(ctx, apiClient, namespace, service.Secrets, config.Secrets) 37 if err != nil { 38 return nil, errors.Wrapf(err, "service %s", service.Name) 39 } 40 configs, err := convertServiceConfigObjs(ctx, apiClient, namespace, service, config.Configs) 41 if err != nil { 42 return nil, errors.Wrapf(err, "service %s", service.Name) 43 } 44 45 serviceSpec, err := Service(apiClient.ClientVersion(), namespace, service, config.Networks, config.Volumes, secrets, configs) 46 if err != nil { 47 return nil, errors.Wrapf(err, "service %s", service.Name) 48 } 49 result[service.Name] = serviceSpec 50 } 51 52 return result, nil 53 } 54 55 // Service converts a ServiceConfig into a swarm ServiceSpec 56 func Service( 57 apiVersion string, 58 namespace Namespace, 59 service composetypes.ServiceConfig, 60 networkConfigs map[string]composetypes.NetworkConfig, 61 volumes map[string]composetypes.VolumeConfig, 62 secrets []*swarm.SecretReference, 63 configs []*swarm.ConfigReference, 64 ) (swarm.ServiceSpec, error) { 65 name := namespace.Scope(service.Name) 66 endpoint := convertEndpointSpec(service.Deploy.EndpointMode, service.Ports) 67 68 mode, err := convertDeployMode(service.Deploy.Mode, service.Deploy.Replicas) 69 if err != nil { 70 return swarm.ServiceSpec{}, err 71 } 72 73 mounts, err := Volumes(service.Volumes, volumes, namespace) 74 if err != nil { 75 return swarm.ServiceSpec{}, err 76 } 77 78 resources, err := convertResources(service.Deploy.Resources) 79 if err != nil { 80 return swarm.ServiceSpec{}, err 81 } 82 83 restartPolicy, err := convertRestartPolicy( 84 service.Restart, service.Deploy.RestartPolicy) 85 if err != nil { 86 return swarm.ServiceSpec{}, err 87 } 88 89 healthcheck, err := convertHealthcheck(service.HealthCheck) 90 if err != nil { 91 return swarm.ServiceSpec{}, err 92 } 93 94 networks, err := convertServiceNetworks(service.Networks, networkConfigs, namespace, service.Name) 95 if err != nil { 96 return swarm.ServiceSpec{}, err 97 } 98 99 dnsConfig := convertDNSConfig(service.DNS, service.DNSSearch) 100 101 var privileges swarm.Privileges 102 privileges.CredentialSpec, err = convertCredentialSpec( 103 namespace, service.CredentialSpec, configs, 104 ) 105 if err != nil { 106 return swarm.ServiceSpec{}, err 107 } 108 109 var logDriver *swarm.Driver 110 if service.Logging != nil { 111 logDriver = &swarm.Driver{ 112 Name: service.Logging.Driver, 113 Options: service.Logging.Options, 114 } 115 } 116 117 capAdd, capDrop := opts.EffectiveCapAddCapDrop(service.CapAdd, service.CapDrop) 118 119 serviceSpec := swarm.ServiceSpec{ 120 Annotations: swarm.Annotations{ 121 Name: name, 122 Labels: AddStackLabel(namespace, service.Deploy.Labels), 123 }, 124 TaskTemplate: swarm.TaskSpec{ 125 ContainerSpec: &swarm.ContainerSpec{ 126 Image: service.Image, 127 Command: service.Entrypoint, 128 Args: service.Command, 129 Hostname: service.Hostname, 130 Hosts: convertExtraHosts(service.ExtraHosts), 131 DNSConfig: dnsConfig, 132 Healthcheck: healthcheck, 133 Env: convertEnvironment(service.Environment), 134 Labels: AddStackLabel(namespace, service.Labels), 135 Dir: service.WorkingDir, 136 User: service.User, 137 Mounts: mounts, 138 StopGracePeriod: composetypes.ConvertDurationPtr(service.StopGracePeriod), 139 StopSignal: service.StopSignal, 140 TTY: service.Tty, 141 OpenStdin: service.StdinOpen, 142 Secrets: secrets, 143 Configs: configs, 144 ReadOnly: service.ReadOnly, 145 Privileges: &privileges, 146 Isolation: container.Isolation(service.Isolation), 147 Init: service.Init, 148 Sysctls: service.Sysctls, 149 CapabilityAdd: capAdd, 150 CapabilityDrop: capDrop, 151 Ulimits: convertUlimits(service.Ulimits), 152 }, 153 LogDriver: logDriver, 154 Resources: resources, 155 RestartPolicy: restartPolicy, 156 Placement: &swarm.Placement{ 157 Constraints: service.Deploy.Placement.Constraints, 158 Preferences: getPlacementPreference(service.Deploy.Placement.Preferences), 159 MaxReplicas: service.Deploy.Placement.MaxReplicas, 160 }, 161 }, 162 EndpointSpec: endpoint, 163 Mode: mode, 164 UpdateConfig: convertUpdateConfig(service.Deploy.UpdateConfig), 165 RollbackConfig: convertUpdateConfig(service.Deploy.RollbackConfig), 166 } 167 168 // add an image label to serviceSpec 169 serviceSpec.Labels[LabelImage] = service.Image 170 171 // ServiceSpec.Networks is deprecated and should not have been used by 172 // this package. It is possible to update TaskTemplate.Networks, but it 173 // is not possible to update ServiceSpec.Networks. Unfortunately, we 174 // can't unconditionally start using TaskTemplate.Networks, because that 175 // will break with older daemons that don't support migrating from 176 // ServiceSpec.Networks to TaskTemplate.Networks. So which field to use 177 // is conditional on daemon version. 178 if versions.LessThan(apiVersion, "1.29") { 179 serviceSpec.Networks = networks //nolint:staticcheck // ignore SA1019: field is deprecated. 180 } else { 181 serviceSpec.TaskTemplate.Networks = networks 182 } 183 return serviceSpec, nil 184 } 185 186 func getPlacementPreference(preferences []composetypes.PlacementPreferences) []swarm.PlacementPreference { 187 result := []swarm.PlacementPreference{} 188 for _, preference := range preferences { 189 spreadDescriptor := preference.Spread 190 result = append(result, swarm.PlacementPreference{ 191 Spread: &swarm.SpreadOver{ 192 SpreadDescriptor: spreadDescriptor, 193 }, 194 }) 195 } 196 return result 197 } 198 199 func convertServiceNetworks( 200 networks map[string]*composetypes.ServiceNetworkConfig, 201 networkConfigs networkMap, 202 namespace Namespace, 203 name string, 204 ) ([]swarm.NetworkAttachmentConfig, error) { 205 if len(networks) == 0 { 206 networks = map[string]*composetypes.ServiceNetworkConfig{ 207 defaultNetwork: {}, 208 } 209 } 210 211 nets := []swarm.NetworkAttachmentConfig{} 212 for networkName, network := range networks { 213 networkConfig, ok := networkConfigs[networkName] 214 if !ok && networkName != defaultNetwork { 215 return nil, errors.Errorf("undefined network %q", networkName) 216 } 217 var aliases []string 218 if network != nil { 219 aliases = network.Aliases 220 } 221 target := namespace.Scope(networkName) 222 if networkConfig.Name != "" { 223 target = networkConfig.Name 224 } 225 netAttachConfig := swarm.NetworkAttachmentConfig{ 226 Target: target, 227 Aliases: aliases, 228 } 229 // Only add default aliases to user defined networks. Other networks do 230 // not support aliases. 231 if container.NetworkMode(target).IsUserDefined() { 232 netAttachConfig.Aliases = append(netAttachConfig.Aliases, name) 233 } 234 nets = append(nets, netAttachConfig) 235 } 236 237 sort.Slice(nets, func(i, j int) bool { 238 return nets[i].Target < nets[j].Target 239 }) 240 return nets, nil 241 } 242 243 // TODO: fix secrets API so that SecretAPIClient is not required here 244 func convertServiceSecrets( 245 ctx context.Context, 246 apiClient client.SecretAPIClient, 247 namespace Namespace, 248 secrets []composetypes.ServiceSecretConfig, 249 secretSpecs map[string]composetypes.SecretConfig, 250 ) ([]*swarm.SecretReference, error) { 251 refs := []*swarm.SecretReference{} 252 253 lookup := func(key string) (composetypes.FileObjectConfig, error) { 254 secretSpec, exists := secretSpecs[key] 255 if !exists { 256 return composetypes.FileObjectConfig{}, errors.Errorf("undefined secret %q", key) 257 } 258 return composetypes.FileObjectConfig(secretSpec), nil 259 } 260 for _, secret := range secrets { 261 obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(secret), lookup) 262 if err != nil { 263 return nil, err 264 } 265 266 file := swarm.SecretReferenceFileTarget(obj.File) 267 refs = append(refs, &swarm.SecretReference{ 268 File: &file, 269 SecretName: obj.Name, 270 }) 271 } 272 273 secrs, err := servicecli.ParseSecrets(ctx, apiClient, refs) 274 if err != nil { 275 return nil, err 276 } 277 // sort to ensure idempotence (don't restart services just because the entries are in different order) 278 sort.SliceStable(secrs, func(i, j int) bool { return secrs[i].SecretName < secrs[j].SecretName }) 279 return secrs, err 280 } 281 282 // convertServiceConfigObjs takes an API client, a namespace, a ServiceConfig, 283 // and a set of compose Config specs, and creates the swarm ConfigReferences 284 // required by the serivce. Unlike convertServiceSecrets, this takes the whole 285 // ServiceConfig, because some Configs may be needed as a result of other 286 // fields (like CredentialSpecs). 287 // 288 // TODO: fix configs API so that ConfigsAPIClient is not required here 289 func convertServiceConfigObjs( 290 ctx context.Context, 291 apiClient client.ConfigAPIClient, 292 namespace Namespace, 293 service composetypes.ServiceConfig, 294 configSpecs map[string]composetypes.ConfigObjConfig, 295 ) ([]*swarm.ConfigReference, error) { 296 refs := []*swarm.ConfigReference{} 297 298 lookup := func(key string) (composetypes.FileObjectConfig, error) { 299 configSpec, exists := configSpecs[key] 300 if !exists { 301 return composetypes.FileObjectConfig{}, errors.Errorf("undefined config %q", key) 302 } 303 return composetypes.FileObjectConfig(configSpec), nil 304 } 305 for _, config := range service.Configs { 306 obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(config), lookup) 307 if err != nil { 308 return nil, err 309 } 310 311 file := swarm.ConfigReferenceFileTarget(obj.File) 312 refs = append(refs, &swarm.ConfigReference{ 313 File: &file, 314 ConfigName: obj.Name, 315 }) 316 } 317 318 // finally, after converting all of the file objects, create any 319 // Runtime-type configs that are needed. these are configs that are not 320 // mounted into the container, but are used in some other way by the 321 // container runtime. Currently, this only means CredentialSpecs, but in 322 // the future it may be used for other fields 323 324 // grab the CredentialSpec out of the Service 325 credSpec := service.CredentialSpec 326 // if the credSpec uses a config, then we should grab the config name, and 327 // create a config reference for it. A File or Registry-type CredentialSpec 328 // does not need this operation. 329 if credSpec.Config != "" { 330 // look up the config in the configSpecs. 331 obj, err := lookup(credSpec.Config) 332 if err != nil { 333 return nil, err 334 } 335 336 // get the actual correct name. 337 name := namespace.Scope(credSpec.Config) 338 if obj.Name != "" { 339 name = obj.Name 340 } 341 342 // now append a Runtime-type config. 343 refs = append(refs, &swarm.ConfigReference{ 344 ConfigName: name, 345 Runtime: &swarm.ConfigReferenceRuntimeTarget{}, 346 }) 347 } 348 349 confs, err := servicecli.ParseConfigs(ctx, apiClient, refs) 350 if err != nil { 351 return nil, err 352 } 353 // sort to ensure idempotence (don't restart services just because the entries are in different order) 354 sort.SliceStable(confs, func(i, j int) bool { return confs[i].ConfigName < confs[j].ConfigName }) 355 return confs, err 356 } 357 358 type swarmReferenceTarget struct { 359 Name string 360 UID string 361 GID string 362 Mode os.FileMode 363 } 364 365 type swarmReferenceObject struct { 366 File swarmReferenceTarget 367 ID string 368 Name string 369 } 370 371 func convertFileObject( 372 namespace Namespace, 373 config composetypes.FileReferenceConfig, 374 lookup func(key string) (composetypes.FileObjectConfig, error), 375 ) (swarmReferenceObject, error) { 376 obj, err := lookup(config.Source) 377 if err != nil { 378 return swarmReferenceObject{}, err 379 } 380 381 source := namespace.Scope(config.Source) 382 if obj.Name != "" { 383 source = obj.Name 384 } 385 386 target := config.Target 387 if target == "" { 388 target = config.Source 389 } 390 391 uid := config.UID 392 gid := config.GID 393 if uid == "" { 394 uid = "0" 395 } 396 if gid == "" { 397 gid = "0" 398 } 399 mode := config.Mode 400 if mode == nil { 401 mode = uint32Ptr(0o444) 402 } 403 404 return swarmReferenceObject{ 405 File: swarmReferenceTarget{ 406 Name: target, 407 UID: uid, 408 GID: gid, 409 Mode: os.FileMode(*mode), 410 }, 411 Name: source, 412 }, nil 413 } 414 415 func uint32Ptr(value uint32) *uint32 { 416 return &value 417 } 418 419 // convertExtraHosts converts <host>:<ip> mappings to SwarmKit notation: 420 // "IP-address hostname(s)". The original order of mappings is preserved. 421 func convertExtraHosts(extraHosts composetypes.HostsList) []string { 422 hosts := make([]string, 0, len(extraHosts)) 423 for _, hostIP := range extraHosts { 424 if hostName, ipAddr, ok := strings.Cut(hostIP, ":"); ok { 425 // Convert to SwarmKit notation: IP-address hostname(s) 426 hosts = append(hosts, ipAddr+" "+hostName) 427 } 428 } 429 return hosts 430 } 431 432 func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container.HealthConfig, error) { 433 if healthcheck == nil { 434 return nil, nil 435 } 436 var ( 437 timeout, interval, startPeriod, startInterval time.Duration 438 retries int 439 ) 440 if healthcheck.Disable { 441 if len(healthcheck.Test) != 0 { 442 return nil, errors.Errorf("test and disable can't be set at the same time") 443 } 444 return &container.HealthConfig{ 445 Test: []string{"NONE"}, 446 }, nil 447 } 448 if healthcheck.Timeout != nil { 449 timeout = time.Duration(*healthcheck.Timeout) 450 } 451 if healthcheck.Interval != nil { 452 interval = time.Duration(*healthcheck.Interval) 453 } 454 if healthcheck.StartPeriod != nil { 455 startPeriod = time.Duration(*healthcheck.StartPeriod) 456 } 457 if healthcheck.StartInterval != nil { 458 startInterval = time.Duration(*healthcheck.StartInterval) 459 } 460 if healthcheck.Retries != nil { 461 retries = int(*healthcheck.Retries) 462 } 463 return &container.HealthConfig{ 464 Test: healthcheck.Test, 465 Timeout: timeout, 466 Interval: interval, 467 Retries: retries, 468 StartPeriod: startPeriod, 469 StartInterval: startInterval, 470 }, nil 471 } 472 473 func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) { 474 // TODO: log if restart is being ignored 475 if source == nil { 476 policy, err := opts.ParseRestartPolicy(restart) 477 if err != nil { 478 return nil, err 479 } 480 switch { 481 case policy.IsNone(): 482 return nil, nil 483 case policy.IsAlways(), policy.IsUnlessStopped(): 484 return &swarm.RestartPolicy{ 485 Condition: swarm.RestartPolicyConditionAny, 486 }, nil 487 case policy.IsOnFailure(): 488 attempts := uint64(policy.MaximumRetryCount) 489 return &swarm.RestartPolicy{ 490 Condition: swarm.RestartPolicyConditionOnFailure, 491 MaxAttempts: &attempts, 492 }, nil 493 default: 494 return nil, errors.Errorf("unknown restart policy: %s", restart) 495 } 496 } 497 498 return &swarm.RestartPolicy{ 499 Condition: swarm.RestartPolicyCondition(source.Condition), 500 Delay: composetypes.ConvertDurationPtr(source.Delay), 501 MaxAttempts: source.MaxAttempts, 502 Window: composetypes.ConvertDurationPtr(source.Window), 503 }, nil 504 } 505 506 func convertUpdateConfig(source *composetypes.UpdateConfig) *swarm.UpdateConfig { 507 if source == nil { 508 return nil 509 } 510 parallel := uint64(1) 511 if source.Parallelism != nil { 512 parallel = *source.Parallelism 513 } 514 return &swarm.UpdateConfig{ 515 Parallelism: parallel, 516 Delay: time.Duration(source.Delay), 517 FailureAction: source.FailureAction, 518 Monitor: time.Duration(source.Monitor), 519 MaxFailureRatio: source.MaxFailureRatio, 520 Order: source.Order, 521 } 522 } 523 524 func convertResources(source composetypes.Resources) (*swarm.ResourceRequirements, error) { 525 resources := &swarm.ResourceRequirements{} 526 var err error 527 if source.Limits != nil { 528 var cpus int64 529 if source.Limits.NanoCPUs != "" { 530 cpus, err = opts.ParseCPUs(source.Limits.NanoCPUs) 531 if err != nil { 532 return nil, err 533 } 534 } 535 resources.Limits = &swarm.Limit{ 536 NanoCPUs: cpus, 537 MemoryBytes: int64(source.Limits.MemoryBytes), 538 Pids: source.Limits.Pids, 539 } 540 } 541 if source.Reservations != nil { 542 var cpus int64 543 if source.Reservations.NanoCPUs != "" { 544 cpus, err = opts.ParseCPUs(source.Reservations.NanoCPUs) 545 if err != nil { 546 return nil, err 547 } 548 } 549 550 var generic []swarm.GenericResource 551 for _, res := range source.Reservations.GenericResources { 552 var r swarm.GenericResource 553 554 if res.DiscreteResourceSpec != nil { 555 r.DiscreteResourceSpec = &swarm.DiscreteGenericResource{ 556 Kind: res.DiscreteResourceSpec.Kind, 557 Value: res.DiscreteResourceSpec.Value, 558 } 559 } 560 561 generic = append(generic, r) 562 } 563 564 resources.Reservations = &swarm.Resources{ 565 NanoCPUs: cpus, 566 MemoryBytes: int64(source.Reservations.MemoryBytes), 567 GenericResources: generic, 568 } 569 } 570 return resources, nil 571 } 572 573 func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortConfig) *swarm.EndpointSpec { 574 portConfigs := []swarm.PortConfig{} 575 for _, port := range source { 576 portConfig := swarm.PortConfig{ 577 Protocol: swarm.PortConfigProtocol(port.Protocol), 578 TargetPort: port.Target, 579 PublishedPort: port.Published, 580 PublishMode: swarm.PortConfigPublishMode(port.Mode), 581 } 582 portConfigs = append(portConfigs, portConfig) 583 } 584 585 sort.Slice(portConfigs, func(i, j int) bool { 586 return portConfigs[i].PublishedPort < portConfigs[j].PublishedPort 587 }) 588 589 return &swarm.EndpointSpec{ 590 Mode: swarm.ResolutionMode(strings.ToLower(endpointMode)), 591 Ports: portConfigs, 592 } 593 } 594 595 // convertEnvironment converts key/value mappings to a slice, and sorts 596 // the results. 597 func convertEnvironment(source map[string]*string) []string { 598 var output []string 599 600 for name, value := range source { 601 switch value { 602 case nil: 603 output = append(output, name) 604 default: 605 output = append(output, name+"="+*value) 606 } 607 } 608 sort.Strings(output) 609 return output 610 } 611 612 func convertDeployMode(mode string, replicas *uint64) (swarm.ServiceMode, error) { 613 serviceMode := swarm.ServiceMode{} 614 615 switch mode { 616 case "global-job": 617 if replicas != nil { 618 return serviceMode, errors.Errorf("replicas can only be used with replicated or replicated-job mode") 619 } 620 serviceMode.GlobalJob = &swarm.GlobalJob{} 621 case "global": 622 if replicas != nil { 623 return serviceMode, errors.Errorf("replicas can only be used with replicated or replicated-job mode") 624 } 625 serviceMode.Global = &swarm.GlobalService{} 626 case "replicated-job": 627 serviceMode.ReplicatedJob = &swarm.ReplicatedJob{ 628 MaxConcurrent: replicas, 629 TotalCompletions: replicas, 630 } 631 case "replicated", "": 632 serviceMode.Replicated = &swarm.ReplicatedService{Replicas: replicas} 633 default: 634 return serviceMode, errors.Errorf("Unknown mode: %s", mode) 635 } 636 return serviceMode, nil 637 } 638 639 func convertDNSConfig(dns []string, dnsSearch []string) *swarm.DNSConfig { 640 if dns != nil || dnsSearch != nil { 641 return &swarm.DNSConfig{ 642 Nameservers: dns, 643 Search: dnsSearch, 644 } 645 } 646 return nil 647 } 648 649 func convertCredentialSpec(namespace Namespace, spec composetypes.CredentialSpecConfig, refs []*swarm.ConfigReference) (*swarm.CredentialSpec, error) { 650 var o []string 651 652 // Config was added in API v1.40 653 if spec.Config != "" { 654 o = append(o, `"Config"`) 655 } 656 if spec.File != "" { 657 o = append(o, `"File"`) 658 } 659 if spec.Registry != "" { 660 o = append(o, `"Registry"`) 661 } 662 l := len(o) 663 switch { 664 case l == 0: 665 return nil, nil 666 case l == 2: 667 return nil, errors.Errorf("invalid credential spec: cannot specify both %s and %s", o[0], o[1]) 668 case l > 2: 669 return nil, errors.Errorf("invalid credential spec: cannot specify both %s, and %s", strings.Join(o[:l-1], ", "), o[l-1]) 670 } 671 swarmCredSpec := swarm.CredentialSpec(spec) 672 // if we're using a swarm Config for the credential spec, over-write it 673 // here with the config ID 674 if swarmCredSpec.Config != "" { 675 for _, config := range refs { 676 if swarmCredSpec.Config == config.ConfigName { 677 swarmCredSpec.Config = config.ConfigID 678 return &swarmCredSpec, nil 679 } 680 } 681 // if none of the configs match, try namespacing 682 for _, config := range refs { 683 if namespace.Scope(swarmCredSpec.Config) == config.ConfigName { 684 swarmCredSpec.Config = config.ConfigID 685 return &swarmCredSpec, nil 686 } 687 } 688 return nil, errors.Errorf("invalid credential spec: spec specifies config %v, but no such config can be found", swarmCredSpec.Config) 689 } 690 return &swarmCredSpec, nil 691 } 692 693 func convertUlimits(origUlimits map[string]*composetypes.UlimitsConfig) []*units.Ulimit { 694 newUlimits := make(map[string]*units.Ulimit) 695 for name, u := range origUlimits { 696 if u.Single != 0 { 697 newUlimits[name] = &units.Ulimit{ 698 Name: name, 699 Soft: int64(u.Single), 700 Hard: int64(u.Single), 701 } 702 } else { 703 newUlimits[name] = &units.Ulimit{ 704 Name: name, 705 Soft: int64(u.Soft), 706 Hard: int64(u.Hard), 707 } 708 } 709 } 710 ulimits := make([]*units.Ulimit, 0, len(newUlimits)) 711 for _, ulimit := range newUlimits { 712 ulimits = append(ulimits, ulimit) 713 } 714 sort.SliceStable(ulimits, func(i, j int) bool { 715 return ulimits[i].Name < ulimits[j].Name 716 }) 717 return ulimits 718 }