convert.go 13 KB

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