convert.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package ecs
  14. import (
  15. "encoding/json"
  16. "fmt"
  17. "os"
  18. "path/filepath"
  19. "sort"
  20. "strconv"
  21. "strings"
  22. "time"
  23. "github.com/aws/aws-sdk-go/aws"
  24. ecsapi "github.com/aws/aws-sdk-go/service/ecs"
  25. "github.com/awslabs/goformation/v4/cloudformation"
  26. "github.com/awslabs/goformation/v4/cloudformation/ecs"
  27. "github.com/compose-spec/compose-go/types"
  28. "github.com/docker/cli/opts"
  29. "github.com/joho/godotenv"
  30. "github.com/docker/compose-cli/ecs/secrets"
  31. )
  32. const secretsInitContainerImage = "docker/ecs-secrets-sidecar"
  33. func convert(project *types.Project, service types.ServiceConfig) (*ecs.TaskDefinition, error) {
  34. cpu, mem, err := toLimits(service)
  35. if err != nil {
  36. return nil, err
  37. }
  38. _, memReservation := toContainerReservation(service)
  39. credential := getRepoCredentials(service)
  40. // override resolve.conf search directive to also search <project>.local
  41. // TODO remove once ECS support hostname-only service discovery
  42. service.Environment["LOCALDOMAIN"] = aws.String(
  43. cloudformation.Join("", []string{
  44. cloudformation.Ref("AWS::Region"),
  45. ".compute.internal",
  46. fmt.Sprintf(" %s.local", project.Name),
  47. }))
  48. logConfiguration := getLogConfiguration(service, project)
  49. var (
  50. initContainers []ecs.TaskDefinition_ContainerDefinition
  51. volumes []ecs.TaskDefinition_Volume
  52. mounts []ecs.TaskDefinition_MountPoint
  53. )
  54. if len(service.Secrets) > 0 {
  55. secretsVolume, secretsMount, secretsSideCar, err := createSecretsSideCar(project, service, logConfiguration)
  56. if err != nil {
  57. return nil, err
  58. }
  59. initContainers = append(initContainers, secretsSideCar)
  60. volumes = append(volumes, secretsVolume)
  61. mounts = append(mounts, secretsMount)
  62. }
  63. var dependencies []ecs.TaskDefinition_ContainerDependency
  64. for _, c := range initContainers {
  65. dependencies = append(dependencies, ecs.TaskDefinition_ContainerDependency{
  66. Condition: ecsapi.ContainerConditionSuccess,
  67. ContainerName: c.Name,
  68. })
  69. }
  70. for _, v := range service.Volumes {
  71. source := project.Volumes[v.Source]
  72. volumes = append(volumes, ecs.TaskDefinition_Volume{
  73. EFSVolumeConfiguration: &ecs.TaskDefinition_EFSVolumeConfiguration{
  74. FilesystemId: source.Name,
  75. RootDirectory: source.DriverOpts["root_directory"],
  76. },
  77. Name: v.Source,
  78. })
  79. mounts = append(mounts, ecs.TaskDefinition_MountPoint{
  80. ContainerPath: v.Target,
  81. ReadOnly: v.ReadOnly,
  82. SourceVolume: v.Source,
  83. })
  84. }
  85. pairs, err := createEnvironment(project, service)
  86. if err != nil {
  87. return nil, err
  88. }
  89. var reservations *types.Resource
  90. if service.Deploy != nil && service.Deploy.Resources.Reservations != nil {
  91. reservations = service.Deploy.Resources.Reservations
  92. }
  93. containers := append(initContainers, ecs.TaskDefinition_ContainerDefinition{
  94. Command: service.Command,
  95. DisableNetworking: service.NetworkMode == "none",
  96. DependsOnProp: dependencies,
  97. DnsSearchDomains: service.DNSSearch,
  98. DnsServers: service.DNS,
  99. DockerSecurityOptions: service.SecurityOpt,
  100. EntryPoint: service.Entrypoint,
  101. Environment: pairs,
  102. Essential: true,
  103. ExtraHosts: toHostEntryPtr(service.ExtraHosts),
  104. FirelensConfiguration: nil,
  105. HealthCheck: toHealthCheck(service.HealthCheck),
  106. Hostname: service.Hostname,
  107. Image: service.Image,
  108. Interactive: false,
  109. Links: nil,
  110. LinuxParameters: toLinuxParameters(service),
  111. LogConfiguration: logConfiguration,
  112. MemoryReservation: memReservation,
  113. MountPoints: mounts,
  114. Name: service.Name,
  115. PortMappings: toPortMappings(service.Ports),
  116. Privileged: service.Privileged,
  117. PseudoTerminal: service.Tty,
  118. ReadonlyRootFilesystem: service.ReadOnly,
  119. RepositoryCredentials: credential,
  120. ResourceRequirements: toTaskResourceRequirements(reservations),
  121. StartTimeout: 0,
  122. StopTimeout: durationToInt(service.StopGracePeriod),
  123. SystemControls: toSystemControls(service.Sysctls),
  124. Ulimits: toUlimits(service.Ulimits),
  125. User: service.User,
  126. VolumesFrom: nil,
  127. WorkingDirectory: service.WorkingDir,
  128. })
  129. launchType := ecsapi.LaunchTypeFargate
  130. if requireEC2(service) {
  131. launchType = ecsapi.LaunchTypeEc2
  132. }
  133. return &ecs.TaskDefinition{
  134. ContainerDefinitions: containers,
  135. Cpu: cpu,
  136. Family: fmt.Sprintf("%s-%s", project.Name, service.Name),
  137. IpcMode: service.Ipc,
  138. Memory: mem,
  139. NetworkMode: ecsapi.NetworkModeAwsvpc, // FIXME could be set by service.NetworkMode, Fargate only supports network mode ‘awsvpc’.
  140. PidMode: service.Pid,
  141. PlacementConstraints: toPlacementConstraints(service.Deploy),
  142. ProxyConfiguration: nil,
  143. RequiresCompatibilities: []string{
  144. launchType,
  145. },
  146. Volumes: volumes,
  147. }, nil
  148. }
  149. func toTaskResourceRequirements(reservations *types.Resource) []ecs.TaskDefinition_ResourceRequirement {
  150. if reservations == nil {
  151. return nil
  152. }
  153. var requirements []ecs.TaskDefinition_ResourceRequirement
  154. for _, r := range reservations.GenericResources {
  155. if r.DiscreteResourceSpec.Kind == "gpus" {
  156. requirements = append(requirements, ecs.TaskDefinition_ResourceRequirement{
  157. Type: ecsapi.ResourceTypeGpu,
  158. Value: fmt.Sprint(r.DiscreteResourceSpec.Value),
  159. })
  160. }
  161. }
  162. return requirements
  163. }
  164. func createSecretsSideCar(project *types.Project, service types.ServiceConfig, logConfiguration *ecs.TaskDefinition_LogConfiguration) (
  165. ecs.TaskDefinition_Volume,
  166. ecs.TaskDefinition_MountPoint,
  167. ecs.TaskDefinition_ContainerDefinition,
  168. error) {
  169. initContainerName := fmt.Sprintf("%s_Secrets_InitContainer", normalizeResourceName(service.Name))
  170. secretsVolume := ecs.TaskDefinition_Volume{
  171. Name: "secrets",
  172. }
  173. secretsMount := ecs.TaskDefinition_MountPoint{
  174. ContainerPath: "/run/secrets/",
  175. ReadOnly: true,
  176. SourceVolume: "secrets",
  177. }
  178. var (
  179. args []secrets.Secret
  180. taskSecrets []ecs.TaskDefinition_Secret
  181. )
  182. for _, s := range service.Secrets {
  183. secretConfig := project.Secrets[s.Source]
  184. if s.Target == "" {
  185. s.Target = s.Source
  186. }
  187. taskSecrets = append(taskSecrets, ecs.TaskDefinition_Secret{
  188. Name: s.Target,
  189. ValueFrom: secretConfig.Name,
  190. })
  191. var keys []string
  192. if ext, ok := secretConfig.Extensions[extensionKeys]; ok {
  193. if key, ok := ext.(string); ok {
  194. keys = append(keys, key)
  195. } else {
  196. for _, k := range ext.([]interface{}) {
  197. keys = append(keys, k.(string))
  198. }
  199. }
  200. }
  201. args = append(args, secrets.Secret{
  202. Name: s.Target,
  203. Keys: keys,
  204. })
  205. }
  206. command, err := json.Marshal(args)
  207. if err != nil {
  208. return ecs.TaskDefinition_Volume{}, ecs.TaskDefinition_MountPoint{}, ecs.TaskDefinition_ContainerDefinition{}, err
  209. }
  210. secretsSideCar := ecs.TaskDefinition_ContainerDefinition{
  211. Name: initContainerName,
  212. Image: secretsInitContainerImage,
  213. Command: []string{string(command)},
  214. Essential: false, // FIXME this will be ignored, see https://github.com/awslabs/goformation/issues/61#issuecomment-625139607
  215. LogConfiguration: logConfiguration,
  216. MountPoints: []ecs.TaskDefinition_MountPoint{
  217. {
  218. ContainerPath: "/run/secrets/",
  219. ReadOnly: false,
  220. SourceVolume: "secrets",
  221. },
  222. },
  223. Secrets: taskSecrets,
  224. }
  225. return secretsVolume, secretsMount, secretsSideCar, nil
  226. }
  227. func createEnvironment(project *types.Project, service types.ServiceConfig) ([]ecs.TaskDefinition_KeyValuePair, error) {
  228. environment := map[string]*string{}
  229. for _, f := range service.EnvFile {
  230. if !filepath.IsAbs(f) {
  231. f = filepath.Join(project.WorkingDir, f)
  232. }
  233. if _, err := os.Stat(f); os.IsNotExist(err) {
  234. return nil, err
  235. }
  236. file, err := os.Open(f)
  237. if err != nil {
  238. return nil, err
  239. }
  240. defer file.Close() // nolint:errcheck
  241. env, err := godotenv.Parse(file)
  242. if err != nil {
  243. return nil, err
  244. }
  245. for k, v := range env {
  246. environment[k] = &v
  247. }
  248. }
  249. for k, v := range service.Environment {
  250. environment[k] = v
  251. }
  252. var pairs []ecs.TaskDefinition_KeyValuePair
  253. for k, v := range environment {
  254. name := k
  255. var value string
  256. if v != nil {
  257. value = *v
  258. }
  259. pairs = append(pairs, ecs.TaskDefinition_KeyValuePair{
  260. Name: name,
  261. Value: value,
  262. })
  263. }
  264. return pairs, nil
  265. }
  266. func getLogConfiguration(service types.ServiceConfig, project *types.Project) *ecs.TaskDefinition_LogConfiguration {
  267. options := map[string]string{
  268. "awslogs-region": cloudformation.Ref("AWS::Region"),
  269. "awslogs-group": cloudformation.Ref("LogGroup"),
  270. "awslogs-stream-prefix": project.Name,
  271. }
  272. if service.Logging != nil {
  273. for k, v := range service.Logging.Options {
  274. if strings.HasPrefix(k, "awslogs-") {
  275. options[k] = v
  276. }
  277. }
  278. }
  279. logConfiguration := &ecs.TaskDefinition_LogConfiguration{
  280. LogDriver: ecsapi.LogDriverAwslogs,
  281. Options: options,
  282. }
  283. return logConfiguration
  284. }
  285. func toSystemControls(sysctls types.Mapping) []ecs.TaskDefinition_SystemControl {
  286. sys := []ecs.TaskDefinition_SystemControl{}
  287. for k, v := range sysctls {
  288. sys = append(sys, ecs.TaskDefinition_SystemControl{
  289. Namespace: k,
  290. Value: v,
  291. })
  292. }
  293. return sys
  294. }
  295. const miB = 1024 * 1024
  296. func toLimits(service types.ServiceConfig) (string, string, error) {
  297. mem, cpu, err := getConfiguredLimits(service)
  298. if err != nil {
  299. return "", "", err
  300. }
  301. if requireEC2(service) {
  302. // just return configured limits expressed in Mb and CPU units
  303. var cpuLimit, memLimit string
  304. if cpu > 0 {
  305. cpuLimit = fmt.Sprint(cpu)
  306. }
  307. if mem > 0 {
  308. memLimit = fmt.Sprint(mem / miB)
  309. }
  310. return cpuLimit, memLimit, nil
  311. }
  312. // All possible cpu/mem values for Fargate
  313. fargateCPUToMem := map[int64][]types.UnitBytes{
  314. 256: {512, 1024, 2048},
  315. 512: {1024, 2048, 3072, 4096},
  316. 1024: {2048, 3072, 4096, 5120, 6144, 7168, 8192},
  317. 2048: {4096, 5120, 6144, 7168, 8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384},
  318. 4096: {8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384, 17408, 18432, 19456, 20480, 21504, 22528, 23552, 24576, 25600, 26624, 27648, 28672, 29696, 30720},
  319. }
  320. cpuLimit := "256"
  321. memLimit := "512"
  322. if mem == 0 && cpu == 0 {
  323. return cpuLimit, memLimit, nil
  324. }
  325. var cpus []int64
  326. for k := range fargateCPUToMem {
  327. cpus = append(cpus, k)
  328. }
  329. sort.Slice(cpus, func(i, j int) bool { return cpus[i] < cpus[j] })
  330. for _, fargateCPU := range cpus {
  331. options := fargateCPUToMem[fargateCPU]
  332. if cpu <= fargateCPU {
  333. for _, m := range options {
  334. if mem <= m*miB {
  335. cpuLimit = strconv.FormatInt(fargateCPU, 10)
  336. memLimit = strconv.FormatInt(int64(m), 10)
  337. return cpuLimit, memLimit, nil
  338. }
  339. }
  340. }
  341. }
  342. return "", "", fmt.Errorf("the resources requested are not supported by ECS/Fargate")
  343. }
  344. func getConfiguredLimits(service types.ServiceConfig) (types.UnitBytes, int64, error) {
  345. if service.Deploy == nil {
  346. return 0, 0, nil
  347. }
  348. limits := service.Deploy.Resources.Limits
  349. if limits == nil {
  350. return 0, 0, nil
  351. }
  352. if limits.NanoCPUs == "" {
  353. return limits.MemoryBytes, 0, nil
  354. }
  355. v, err := opts.ParseCPUs(limits.NanoCPUs)
  356. if err != nil {
  357. return 0, 0, err
  358. }
  359. return limits.MemoryBytes, v / 1e6, nil
  360. }
  361. func toContainerReservation(service types.ServiceConfig) (string, int) {
  362. cpuReservation := ".0"
  363. memReservation := 0
  364. if service.Deploy == nil {
  365. return cpuReservation, memReservation
  366. }
  367. reservations := service.Deploy.Resources.Reservations
  368. if reservations == nil {
  369. return cpuReservation, memReservation
  370. }
  371. return reservations.NanoCPUs, int(reservations.MemoryBytes / miB)
  372. }
  373. func toPlacementConstraints(deploy *types.DeployConfig) []ecs.TaskDefinition_TaskDefinitionPlacementConstraint {
  374. if deploy == nil || deploy.Placement.Constraints == nil || len(deploy.Placement.Constraints) == 0 {
  375. return nil
  376. }
  377. pl := []ecs.TaskDefinition_TaskDefinitionPlacementConstraint{}
  378. for _, c := range deploy.Placement.Constraints {
  379. pl = append(pl, ecs.TaskDefinition_TaskDefinitionPlacementConstraint{
  380. Expression: c,
  381. Type: "",
  382. })
  383. }
  384. return pl
  385. }
  386. func toPortMappings(ports []types.ServicePortConfig) []ecs.TaskDefinition_PortMapping {
  387. if len(ports) == 0 {
  388. return nil
  389. }
  390. m := []ecs.TaskDefinition_PortMapping{}
  391. for _, p := range ports {
  392. m = append(m, ecs.TaskDefinition_PortMapping{
  393. ContainerPort: int(p.Target),
  394. HostPort: int(p.Published),
  395. Protocol: p.Protocol,
  396. })
  397. }
  398. return m
  399. }
  400. func toUlimits(ulimits map[string]*types.UlimitsConfig) []ecs.TaskDefinition_Ulimit {
  401. if len(ulimits) == 0 {
  402. return nil
  403. }
  404. u := []ecs.TaskDefinition_Ulimit{}
  405. for k, v := range ulimits {
  406. u = append(u, ecs.TaskDefinition_Ulimit{
  407. Name: k,
  408. SoftLimit: v.Soft,
  409. HardLimit: v.Hard,
  410. })
  411. }
  412. return u
  413. }
  414. func toLinuxParameters(service types.ServiceConfig) *ecs.TaskDefinition_LinuxParameters {
  415. return &ecs.TaskDefinition_LinuxParameters{
  416. Capabilities: toKernelCapabilities(service.CapAdd, service.CapDrop),
  417. Devices: nil,
  418. InitProcessEnabled: service.Init != nil && *service.Init,
  419. MaxSwap: 0,
  420. // FIXME SharedMemorySize: service.ShmSize,
  421. Swappiness: 0,
  422. Tmpfs: toTmpfs(service.Tmpfs),
  423. }
  424. }
  425. func toTmpfs(tmpfs types.StringList) []ecs.TaskDefinition_Tmpfs {
  426. if tmpfs == nil || len(tmpfs) == 0 {
  427. return nil
  428. }
  429. o := []ecs.TaskDefinition_Tmpfs{}
  430. for _, path := range tmpfs {
  431. o = append(o, ecs.TaskDefinition_Tmpfs{
  432. ContainerPath: path,
  433. Size: 100, // size is required on ECS, unlimited by the compose spec
  434. })
  435. }
  436. return o
  437. }
  438. func toKernelCapabilities(add []string, drop []string) *ecs.TaskDefinition_KernelCapabilities {
  439. if len(add) == 0 && len(drop) == 0 {
  440. return nil
  441. }
  442. return &ecs.TaskDefinition_KernelCapabilities{
  443. Add: add,
  444. Drop: drop,
  445. }
  446. }
  447. func toHealthCheck(check *types.HealthCheckConfig) *ecs.TaskDefinition_HealthCheck {
  448. if check == nil {
  449. return nil
  450. }
  451. retries := 0
  452. if check.Retries != nil {
  453. retries = int(*check.Retries)
  454. }
  455. return &ecs.TaskDefinition_HealthCheck{
  456. Command: check.Test,
  457. Interval: durationToInt(check.Interval),
  458. Retries: retries,
  459. StartPeriod: durationToInt(check.StartPeriod),
  460. Timeout: durationToInt(check.Timeout),
  461. }
  462. }
  463. func durationToInt(interval *types.Duration) int {
  464. if interval == nil {
  465. return 0
  466. }
  467. v := int(time.Duration(*interval).Seconds())
  468. return v
  469. }
  470. func toHostEntryPtr(hosts types.HostsList) []ecs.TaskDefinition_HostEntry {
  471. if hosts == nil || len(hosts) == 0 {
  472. return nil
  473. }
  474. e := []ecs.TaskDefinition_HostEntry{}
  475. for _, h := range hosts {
  476. parts := strings.SplitN(h, ":", 2) // FIXME this should be handled by compose-go
  477. e = append(e, ecs.TaskDefinition_HostEntry{
  478. Hostname: parts[0],
  479. IpAddress: parts[1],
  480. })
  481. }
  482. return e
  483. }
  484. func getRepoCredentials(service types.ServiceConfig) *ecs.TaskDefinition_RepositoryCredentials {
  485. // extract registry and namespace string from image name
  486. for key, value := range service.Extensions {
  487. if key == extensionPullCredentials {
  488. return &ecs.TaskDefinition_RepositoryCredentials{CredentialsParameter: value.(string)}
  489. }
  490. }
  491. return nil
  492. }
  493. func requireEC2(s types.ServiceConfig) bool {
  494. return gpuRequirements(s) > 0
  495. }
  496. func gpuRequirements(s types.ServiceConfig) int64 {
  497. if deploy := s.Deploy; deploy != nil {
  498. if reservations := deploy.Resources.Reservations; reservations != nil {
  499. for _, resource := range reservations.GenericResources {
  500. if resource.DiscreteResourceSpec.Kind == "gpus" {
  501. return resource.DiscreteResourceSpec.Value
  502. }
  503. }
  504. }
  505. }
  506. return 0
  507. }