convert.go 13 KB

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