convert.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  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 convert
  14. import (
  15. "context"
  16. "encoding/base64"
  17. "fmt"
  18. "io/ioutil"
  19. "math"
  20. "os"
  21. "strconv"
  22. "strings"
  23. "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance"
  24. "github.com/Azure/go-autorest/autorest/to"
  25. "github.com/compose-spec/compose-go/types"
  26. "github.com/pkg/errors"
  27. "github.com/docker/compose-cli/aci/login"
  28. "github.com/docker/compose-cli/api/compose"
  29. "github.com/docker/compose-cli/api/containers"
  30. "github.com/docker/compose-cli/context/store"
  31. "github.com/docker/compose-cli/utils/formatter"
  32. )
  33. const (
  34. // StatusRunning name of the ACI running status
  35. StatusRunning = "Running"
  36. // ComposeDNSSidecarName name of the dns sidecar container
  37. ComposeDNSSidecarName = "aci--dns--sidecar"
  38. dnsSidecarImage = "busybox:1.31.1"
  39. azureFileDriverName = "azure_file"
  40. volumeDriveroptsShareNameKey = "share_name"
  41. volumeDriveroptsAccountNameKey = "storage_account_name"
  42. volumeReadOnly = "read_only"
  43. secretInlineMark = "inline:"
  44. )
  45. // ToContainerGroup converts a compose project into a ACI container group
  46. func ToContainerGroup(ctx context.Context, aciContext store.AciContext, p types.Project, storageHelper login.StorageLogin) (containerinstance.ContainerGroup, error) {
  47. project := projectAciHelper(p)
  48. containerGroupName := strings.ToLower(project.Name)
  49. volumesCache, volumesSlice, err := project.getAciFileVolumes(ctx, storageHelper)
  50. if err != nil {
  51. return containerinstance.ContainerGroup{}, err
  52. }
  53. secretVolumes, err := project.getAciSecretVolumes()
  54. if err != nil {
  55. return containerinstance.ContainerGroup{}, err
  56. }
  57. allVolumes := append(volumesSlice, secretVolumes...)
  58. var volumes *[]containerinstance.Volume
  59. if len(allVolumes) > 0 {
  60. volumes = &allVolumes
  61. }
  62. registryCreds, err := getRegistryCredentials(p, newCliRegistryConfLoader())
  63. if err != nil {
  64. return containerinstance.ContainerGroup{}, err
  65. }
  66. var containers []containerinstance.Container
  67. restartPolicy, err := project.getRestartPolicy()
  68. if err != nil {
  69. return containerinstance.ContainerGroup{}, err
  70. }
  71. groupDefinition := containerinstance.ContainerGroup{
  72. Name: &containerGroupName,
  73. Location: &aciContext.Location,
  74. ContainerGroupProperties: &containerinstance.ContainerGroupProperties{
  75. OsType: containerinstance.Linux,
  76. Containers: &containers,
  77. Volumes: volumes,
  78. ImageRegistryCredentials: &registryCreds,
  79. RestartPolicy: restartPolicy,
  80. },
  81. }
  82. var groupPorts []containerinstance.Port
  83. var dnsLabelName *string
  84. for _, s := range project.Services {
  85. service := serviceConfigAciHelper(s)
  86. containerDefinition, err := service.getAciContainer(volumesCache)
  87. if err != nil {
  88. return containerinstance.ContainerGroup{}, err
  89. }
  90. if service.Labels != nil && len(service.Labels) > 0 {
  91. return containerinstance.ContainerGroup{}, errors.New("ACI integration does not support labels in compose applications")
  92. }
  93. containerPorts, serviceGroupPorts, serviceDomainName, err := convertPortsToAci(service)
  94. if err != nil {
  95. return groupDefinition, err
  96. }
  97. containerDefinition.ContainerProperties.Ports = &containerPorts
  98. groupPorts = append(groupPorts, serviceGroupPorts...)
  99. if serviceDomainName != nil {
  100. if dnsLabelName != nil && *serviceDomainName != *dnsLabelName {
  101. return containerinstance.ContainerGroup{}, fmt.Errorf("ACI integration does not support specifying different domain names on services in the same compose application")
  102. }
  103. dnsLabelName = serviceDomainName
  104. }
  105. containers = append(containers, containerDefinition)
  106. }
  107. if len(groupPorts) > 0 {
  108. groupDefinition.ContainerGroupProperties.IPAddress = &containerinstance.IPAddress{
  109. Type: containerinstance.Public,
  110. Ports: &groupPorts,
  111. DNSNameLabel: dnsLabelName,
  112. }
  113. }
  114. if len(containers) > 1 {
  115. dnsSideCar := getDNSSidecar(containers)
  116. containers = append(containers, dnsSideCar)
  117. }
  118. groupDefinition.ContainerGroupProperties.Containers = &containers
  119. return groupDefinition, nil
  120. }
  121. func convertPortsToAci(service serviceConfigAciHelper) ([]containerinstance.ContainerPort, []containerinstance.Port, *string, error) {
  122. var groupPorts []containerinstance.Port
  123. var containerPorts []containerinstance.ContainerPort
  124. for _, portConfig := range service.Ports {
  125. if portConfig.Published != 0 && portConfig.Published != portConfig.Target {
  126. msg := fmt.Sprintf("Port mapping is not supported with ACI, cannot map port %d to %d for container %s",
  127. portConfig.Published, portConfig.Target, service.Name)
  128. return nil, nil, nil, errors.New(msg)
  129. }
  130. portNumber := int32(portConfig.Target)
  131. containerPorts = append(containerPorts, containerinstance.ContainerPort{
  132. Port: to.Int32Ptr(portNumber),
  133. })
  134. groupPorts = append(groupPorts, containerinstance.Port{
  135. Port: to.Int32Ptr(portNumber),
  136. Protocol: containerinstance.TCP,
  137. })
  138. }
  139. var dnsLabelName *string = nil
  140. if service.DomainName != "" {
  141. dnsLabelName = &service.DomainName
  142. }
  143. return containerPorts, groupPorts, dnsLabelName, nil
  144. }
  145. func getDNSSidecar(containers []containerinstance.Container) containerinstance.Container {
  146. var commands []string
  147. for _, container := range containers {
  148. commands = append(commands, fmt.Sprintf("echo 127.0.0.1 %s >> /etc/hosts", *container.Name))
  149. }
  150. // ACI restart policy is currently at container group level, cannot let the sidecar terminate quietly once /etc/hosts has been edited
  151. // Pricing is done at the container group level so letting the sidecar container "sleep" should not impact the price for the whole group
  152. commands = append(commands, "sleep infinity")
  153. alpineCmd := []string{"sh", "-c", strings.Join(commands, ";")}
  154. dnsSideCar := containerinstance.Container{
  155. Name: to.StringPtr(ComposeDNSSidecarName),
  156. ContainerProperties: &containerinstance.ContainerProperties{
  157. Image: to.StringPtr(dnsSidecarImage),
  158. Command: &alpineCmd,
  159. Resources: &containerinstance.ResourceRequirements{
  160. Limits: &containerinstance.ResourceLimits{
  161. MemoryInGB: to.Float64Ptr(0.1), // "The memory requirement should be in incrememts of 0.1 GB."
  162. CPU: to.Float64Ptr(0.01), // "The CPU requirement should be in incrememts of 0.01."
  163. },
  164. Requests: &containerinstance.ResourceRequests{
  165. MemoryInGB: to.Float64Ptr(0.1),
  166. CPU: to.Float64Ptr(0.01),
  167. },
  168. },
  169. },
  170. }
  171. return dnsSideCar
  172. }
  173. type projectAciHelper types.Project
  174. func (p projectAciHelper) getAciSecretVolumes() ([]containerinstance.Volume, error) {
  175. var secretVolumes []containerinstance.Volume
  176. for secretName, filepathToRead := range p.Secrets {
  177. var data []byte
  178. if strings.HasPrefix(filepathToRead.File, secretInlineMark) {
  179. data = []byte(filepathToRead.File[len(secretInlineMark):])
  180. } else {
  181. var err error
  182. data, err = ioutil.ReadFile(filepathToRead.File)
  183. if err != nil {
  184. return secretVolumes, err
  185. }
  186. }
  187. if len(data) == 0 {
  188. continue
  189. }
  190. dataStr := base64.StdEncoding.EncodeToString(data)
  191. secretVolumes = append(secretVolumes, containerinstance.Volume{
  192. Name: to.StringPtr(secretName),
  193. Secret: map[string]*string{
  194. secretName: &dataStr,
  195. },
  196. })
  197. }
  198. return secretVolumes, nil
  199. }
  200. func (p projectAciHelper) getAciFileVolumes(ctx context.Context, helper login.StorageLogin) (map[string]bool, []containerinstance.Volume, error) {
  201. azureFileVolumesMap := make(map[string]bool, len(p.Volumes))
  202. var azureFileVolumesSlice []containerinstance.Volume
  203. for name, v := range p.Volumes {
  204. if v.Driver == azureFileDriverName {
  205. shareName, ok := v.DriverOpts[volumeDriveroptsShareNameKey]
  206. if !ok {
  207. return nil, nil, fmt.Errorf("cannot retrieve fileshare name for Azurefile")
  208. }
  209. accountName, ok := v.DriverOpts[volumeDriveroptsAccountNameKey]
  210. if !ok {
  211. return nil, nil, fmt.Errorf("cannot retrieve account name for Azurefile")
  212. }
  213. readOnly, ok := v.DriverOpts[volumeReadOnly]
  214. if !ok {
  215. readOnly = "false"
  216. }
  217. ro, err := strconv.ParseBool(readOnly)
  218. if err != nil {
  219. return nil, nil, fmt.Errorf("invalid mode %q for volume", readOnly)
  220. }
  221. accountKey, err := helper.GetAzureStorageAccountKey(ctx, accountName)
  222. if err != nil {
  223. return nil, nil, err
  224. }
  225. aciVolume := containerinstance.Volume{
  226. Name: to.StringPtr(name),
  227. AzureFile: &containerinstance.AzureFileVolume{
  228. ShareName: to.StringPtr(shareName),
  229. StorageAccountName: to.StringPtr(accountName),
  230. StorageAccountKey: to.StringPtr(accountKey),
  231. ReadOnly: &ro,
  232. },
  233. }
  234. azureFileVolumesMap[name] = true
  235. azureFileVolumesSlice = append(azureFileVolumesSlice, aciVolume)
  236. }
  237. }
  238. return azureFileVolumesMap, azureFileVolumesSlice, nil
  239. }
  240. func (p projectAciHelper) getRestartPolicy() (containerinstance.ContainerGroupRestartPolicy, error) {
  241. var restartPolicyCondition containerinstance.ContainerGroupRestartPolicy
  242. if len(p.Services) >= 1 {
  243. alreadySpecified := false
  244. restartPolicyCondition = containerinstance.Always
  245. for _, service := range p.Services {
  246. if service.Deploy != nil &&
  247. service.Deploy.RestartPolicy != nil {
  248. if !alreadySpecified {
  249. alreadySpecified = true
  250. restartPolicyCondition = toAciRestartPolicy(service.Deploy.RestartPolicy.Condition)
  251. }
  252. if alreadySpecified && restartPolicyCondition != toAciRestartPolicy(service.Deploy.RestartPolicy.Condition) {
  253. return "", errors.New("ACI integration does not support specifying different restart policies on services in the same compose application")
  254. }
  255. }
  256. }
  257. }
  258. return restartPolicyCondition, nil
  259. }
  260. func toAciRestartPolicy(restartPolicy string) containerinstance.ContainerGroupRestartPolicy {
  261. switch restartPolicy {
  262. case containers.RestartPolicyNone:
  263. return containerinstance.Never
  264. case containers.RestartPolicyAny:
  265. return containerinstance.Always
  266. case containers.RestartPolicyOnFailure:
  267. return containerinstance.OnFailure
  268. default:
  269. return containerinstance.Always
  270. }
  271. }
  272. func toContainerRestartPolicy(aciRestartPolicy containerinstance.ContainerGroupRestartPolicy) string {
  273. switch aciRestartPolicy {
  274. case containerinstance.Never:
  275. return containers.RestartPolicyNone
  276. case containerinstance.Always:
  277. return containers.RestartPolicyAny
  278. case containerinstance.OnFailure:
  279. return containers.RestartPolicyOnFailure
  280. default:
  281. return containers.RestartPolicyAny
  282. }
  283. }
  284. type serviceConfigAciHelper types.ServiceConfig
  285. func (s serviceConfigAciHelper) getAciFileVolumeMounts(volumesCache map[string]bool) ([]containerinstance.VolumeMount, error) {
  286. var aciServiceVolumes []containerinstance.VolumeMount
  287. for _, sv := range s.Volumes {
  288. if !volumesCache[sv.Source] {
  289. return []containerinstance.VolumeMount{}, fmt.Errorf("could not find volume source %q", sv.Source)
  290. }
  291. aciServiceVolumes = append(aciServiceVolumes, containerinstance.VolumeMount{
  292. Name: to.StringPtr(sv.Source),
  293. MountPath: to.StringPtr(sv.Target),
  294. })
  295. }
  296. return aciServiceVolumes, nil
  297. }
  298. func (s serviceConfigAciHelper) getAciSecretsVolumeMounts() []containerinstance.VolumeMount {
  299. var secretVolumeMounts []containerinstance.VolumeMount
  300. for _, secret := range s.Secrets {
  301. secretsMountPath := "/run/secrets"
  302. if secret.Target == "" {
  303. secret.Target = secret.Source
  304. }
  305. // Specifically use "/" here and not filepath.Join() to avoid windows path being sent and used inside containers
  306. secretsMountPath = secretsMountPath + "/" + secret.Target
  307. vmName := strings.Split(secret.Source, "=")[0]
  308. vm := containerinstance.VolumeMount{
  309. Name: to.StringPtr(vmName),
  310. MountPath: to.StringPtr(secretsMountPath),
  311. ReadOnly: to.BoolPtr(true), // TODO Confirm if the secrets are read only
  312. }
  313. secretVolumeMounts = append(secretVolumeMounts, vm)
  314. }
  315. return secretVolumeMounts
  316. }
  317. func (s serviceConfigAciHelper) getAciContainer(volumesCache map[string]bool) (containerinstance.Container, error) {
  318. secretVolumeMounts := s.getAciSecretsVolumeMounts()
  319. aciServiceVolumes, err := s.getAciFileVolumeMounts(volumesCache)
  320. if err != nil {
  321. return containerinstance.Container{}, err
  322. }
  323. allVolumes := append(aciServiceVolumes, secretVolumeMounts...)
  324. var volumes *[]containerinstance.VolumeMount
  325. if len(allVolumes) == 0 {
  326. volumes = nil
  327. } else {
  328. volumes = &allVolumes
  329. }
  330. memLimit := 1. // Default 1 Gb
  331. var cpuLimit float64 = 1
  332. if s.Deploy != nil && s.Deploy.Resources.Limits != nil {
  333. if s.Deploy.Resources.Limits.MemoryBytes != 0 {
  334. memLimit = bytesToGb(s.Deploy.Resources.Limits.MemoryBytes)
  335. }
  336. if s.Deploy.Resources.Limits.NanoCPUs != "" {
  337. cpuLimit, err = strconv.ParseFloat(s.Deploy.Resources.Limits.NanoCPUs, 0)
  338. if err != nil {
  339. return containerinstance.Container{}, err
  340. }
  341. }
  342. }
  343. return containerinstance.Container{
  344. Name: to.StringPtr(s.Name),
  345. ContainerProperties: &containerinstance.ContainerProperties{
  346. Image: to.StringPtr(s.Image),
  347. Command: to.StringSlicePtr(s.Command),
  348. EnvironmentVariables: getEnvVariables(s.Environment),
  349. Resources: &containerinstance.ResourceRequirements{
  350. Limits: &containerinstance.ResourceLimits{
  351. MemoryInGB: to.Float64Ptr(memLimit),
  352. CPU: to.Float64Ptr(cpuLimit),
  353. },
  354. Requests: &containerinstance.ResourceRequests{
  355. MemoryInGB: to.Float64Ptr(memLimit), // TODO: use the memory requests here and not limits
  356. CPU: to.Float64Ptr(cpuLimit), // TODO: use the cpu requests here and not limits
  357. },
  358. },
  359. VolumeMounts: volumes,
  360. },
  361. }, nil
  362. }
  363. func getEnvVariables(composeEnv types.MappingWithEquals) *[]containerinstance.EnvironmentVariable {
  364. result := []containerinstance.EnvironmentVariable{}
  365. for key, value := range composeEnv {
  366. var strValue string
  367. if value == nil {
  368. strValue = os.Getenv(key)
  369. } else {
  370. strValue = *value
  371. }
  372. result = append(result, containerinstance.EnvironmentVariable{
  373. Name: to.StringPtr(key),
  374. Value: to.StringPtr(strValue),
  375. })
  376. }
  377. return &result
  378. }
  379. func bytesToGb(b types.UnitBytes) float64 {
  380. f := float64(b) / 1024 / 1024 / 1024 // from bytes to gigabytes
  381. return math.Round(f*100) / 100
  382. }
  383. // ContainerGroupToServiceStatus convert from an ACI container definition to service status
  384. func ContainerGroupToServiceStatus(containerID string, group containerinstance.ContainerGroup, container containerinstance.Container, region string) compose.ServiceStatus {
  385. var replicas = 1
  386. if GetStatus(container, group) != StatusRunning {
  387. replicas = 0
  388. }
  389. return compose.ServiceStatus{
  390. ID: containerID,
  391. Name: *container.Name,
  392. Ports: formatter.PortsToStrings(ToPorts(group.IPAddress, *container.Ports), fqdn(group, region)),
  393. Replicas: replicas,
  394. Desired: 1,
  395. }
  396. }
  397. func fqdn(group containerinstance.ContainerGroup, region string) string {
  398. fqdn := ""
  399. if group.IPAddress != nil && group.IPAddress.DNSNameLabel != nil && *group.IPAddress.DNSNameLabel != "" {
  400. fqdn = *group.IPAddress.DNSNameLabel + "." + region + ".azurecontainer.io"
  401. }
  402. return fqdn
  403. }
  404. // ContainerGroupToContainer composes a Container from an ACI container definition
  405. func ContainerGroupToContainer(containerID string, cg containerinstance.ContainerGroup, cc containerinstance.Container, region string) containers.Container {
  406. memLimits := 0.
  407. if cc.Resources != nil &&
  408. cc.Resources.Limits != nil &&
  409. cc.Resources.Limits.MemoryInGB != nil {
  410. memLimits = *cc.Resources.Limits.MemoryInGB * 1024 * 1024 * 1024
  411. }
  412. cpuLimit := 0.
  413. if cc.Resources != nil &&
  414. cc.Resources.Limits != nil &&
  415. cc.Resources.Limits.CPU != nil {
  416. cpuLimit = *cc.Resources.Limits.CPU
  417. }
  418. command := ""
  419. if cc.Command != nil {
  420. command = strings.Join(*cc.Command, " ")
  421. }
  422. status := GetStatus(cc, cg)
  423. platform := string(cg.OsType)
  424. var envVars map[string]string = nil
  425. if cc.EnvironmentVariables != nil && len(*cc.EnvironmentVariables) != 0 {
  426. envVars = map[string]string{}
  427. for _, envVar := range *cc.EnvironmentVariables {
  428. envVars[*envVar.Name] = *envVar.Value
  429. }
  430. }
  431. var config *containers.RuntimeConfig = &containers.RuntimeConfig{FQDN: fqdn(cg, region)}
  432. if envVars != nil {
  433. config.Env = envVars
  434. }
  435. c := containers.Container{
  436. ID: containerID,
  437. Status: status,
  438. Image: to.String(cc.Image),
  439. Command: command,
  440. CPUTime: 0,
  441. CPULimit: cpuLimit,
  442. MemoryUsage: 0,
  443. MemoryLimit: uint64(memLimits),
  444. PidsCurrent: 0,
  445. PidsLimit: 0,
  446. Labels: nil,
  447. Ports: ToPorts(cg.IPAddress, *cc.Ports),
  448. Platform: platform,
  449. RestartPolicyCondition: toContainerRestartPolicy(cg.RestartPolicy),
  450. Config: config,
  451. }
  452. return c
  453. }
  454. // GetStatus returns status for the specified container
  455. func GetStatus(container containerinstance.Container, group containerinstance.ContainerGroup) string {
  456. status := compose.UNKNOWN
  457. if group.InstanceView != nil && group.InstanceView.State != nil {
  458. status = "Node " + *group.InstanceView.State
  459. }
  460. if container.InstanceView != nil && container.InstanceView.CurrentState != nil {
  461. status = *container.InstanceView.CurrentState.State
  462. }
  463. return status
  464. }