浏览代码

introduce generate command as alpha command

Signed-off-by: Guillaume Lours <[email protected]>
Guillaume Lours 1 年之前
父节点
当前提交
51ebeb5441

+ 1 - 0
cmd/compose/alpha.go

@@ -33,6 +33,7 @@ func alphaCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service)
 	cmd.AddCommand(
 		vizCommand(p, dockerCli, backend),
 		publishCommand(p, dockerCli, backend),
+		generateCommand(p, backend),
 	)
 	return cmd
 }

+ 82 - 0
cmd/compose/generate.go

@@ -0,0 +1,82 @@
+/*
+   Copyright 2023 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package compose
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/spf13/cobra"
+)
+
+type generateOptions struct {
+	*ProjectOptions
+	Format string
+}
+
+func generateCommand(p *ProjectOptions, backend api.Service) *cobra.Command {
+	opts := generateOptions{
+		ProjectOptions: p,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "generate [OPTIONS] [CONTAINERS...]",
+		Short: "EXPERIMENTAL - Generate a Compose file from existing containers",
+		PreRunE: Adapt(func(ctx context.Context, args []string) error {
+			return nil
+		}),
+		RunE: Adapt(func(ctx context.Context, args []string) error {
+			return runGenerate(ctx, backend, opts, args)
+		}),
+	}
+
+	cmd.Flags().StringVar(&opts.ProjectName, "name", "", "Project name to set in the Compose file")
+	cmd.Flags().StringVar(&opts.ProjectDir, "project-dir", "", "Directory to use for the project")
+	cmd.Flags().StringVar(&opts.Format, "format", "yaml", "Format the output. Values: [yaml | json]")
+	return cmd
+}
+
+func runGenerate(ctx context.Context, backend api.Service, opts generateOptions, containers []string) error {
+	_, _ = fmt.Fprintln(os.Stderr, "generate command is EXPERIMENTAL")
+	if len(containers) == 0 {
+		return fmt.Errorf("at least one container must be specified")
+	}
+	project, err := backend.Generate(ctx, api.GenerateOptions{
+		Containers:  containers,
+		ProjectName: opts.ProjectName,
+	})
+	if err != nil {
+		return err
+	}
+	var content []byte
+	switch opts.Format {
+	case "json":
+		content, err = project.MarshalJSON()
+	case "yaml":
+		content, err = project.MarshalYAML()
+	default:
+		return fmt.Errorf("unsupported format %q", opts.Format)
+	}
+	if err != nil {
+		return err
+	}
+	fmt.Println(string(content))
+
+	return nil
+}

+ 17 - 0
docs/reference/compose_alpha_generate.md

@@ -0,0 +1,17 @@
+# docker compose alpha generate
+
+<!---MARKER_GEN_START-->
+EXPERIMENTAL - Generate a Compose file from existing containers
+
+### Options
+
+| Name            | Type     | Default | Description                               |
+|:----------------|:---------|:--------|:------------------------------------------|
+| `--dry-run`     | `bool`   |         | Execute command in dry run mode           |
+| `--format`      | `string` | `yaml`  | Format the output. Values: [yaml \| json] |
+| `--name`        | `string` |         | Project name to set in the Compose file   |
+| `--project-dir` | `string` |         | Directory to use for the project          |
+
+
+<!---MARKER_GEN_END-->
+

+ 2 - 0
docs/reference/docker_compose_alpha.yaml

@@ -4,9 +4,11 @@ long: Experimental commands
 pname: docker compose
 plink: docker_compose.yaml
 cname:
+    - docker compose alpha generate
     - docker compose alpha publish
     - docker compose alpha viz
 clink:
+    - docker_compose_alpha_generate.yaml
     - docker_compose_alpha_publish.yaml
     - docker_compose_alpha_viz.yaml
 inherited_options:

+ 53 - 0
docs/reference/docker_compose_alpha_generate.yaml

@@ -0,0 +1,53 @@
+command: docker compose alpha generate
+short: EXPERIMENTAL - Generate a Compose file from existing containers
+long: EXPERIMENTAL - Generate a Compose file from existing containers
+usage: docker compose alpha generate [OPTIONS] [CONTAINERS...]
+pname: docker compose alpha
+plink: docker_compose_alpha.yaml
+options:
+    - option: format
+      value_type: string
+      default_value: yaml
+      description: 'Format the output. Values: [yaml | json]'
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+    - option: name
+      value_type: string
+      description: Project name to set in the Compose file
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+    - option: project-dir
+      value_type: string
+      description: Directory to use for the project
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+inherited_options:
+    - option: dry-run
+      value_type: bool
+      default_value: "false"
+      description: Execute command in dry run mode
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
+deprecated: false
+hidden: false
+experimental: false
+experimentalcli: true
+kubernetes: false
+swarm: false
+

+ 9 - 0
pkg/api/api.go

@@ -92,6 +92,8 @@ type Service interface {
 	Scale(ctx context.Context, project *types.Project, options ScaleOptions) error
 	// Export a service container's filesystem as a tar archive
 	Export(ctx context.Context, projectName string, options ExportOptions) error
+	// Generate generates a Compose Project from existing containers
+	Generate(ctx context.Context, options GenerateOptions) (*types.Project, error)
 }
 
 type ScaleOptions struct {
@@ -563,6 +565,13 @@ type ExportOptions struct {
 	Output  string
 }
 
+type GenerateOptions struct {
+	// ProjectName to set in the Compose file
+	ProjectName string
+	// Containers passed in the command line to be used as reference for service definition
+	Containers []string
+}
+
 const (
 	// STARTING indicates that stack is being deployed
 	STARTING string = "Starting"

+ 4 - 1
pkg/compose/compose.go

@@ -176,7 +176,10 @@ func (s *composeService) projectFromName(containers Containers, projectName stri
 	}
 	set := types.Services{}
 	for _, c := range containers {
-		serviceLabel := c.Labels[api.ServiceLabel]
+		serviceLabel, ok := c.Labels[api.ServiceLabel]
+		if !ok {
+			serviceLabel = getCanonicalContainerName(c)
+		}
 		service, ok := set[serviceLabel]
 		if !ok {
 			service = types.ServiceConfig{

+ 247 - 0
pkg/compose/generate.go

@@ -0,0 +1,247 @@
+/*
+   Copyright 2023 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package compose
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/compose-spec/compose-go/v2/types"
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/docker/compose/v2/pkg/utils"
+	moby "github.com/docker/docker/api/types"
+	containerType "github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/mount"
+	"github.com/docker/docker/api/types/network"
+
+	"golang.org/x/exp/maps"
+)
+
+func (s *composeService) Generate(ctx context.Context, options api.GenerateOptions) (*types.Project, error) {
+	filtersListNames := filters.NewArgs()
+	filtersListIDs := filters.NewArgs()
+	for _, containerName := range options.Containers {
+		filtersListNames.Add("name", containerName)
+		filtersListIDs.Add("id", containerName)
+	}
+	containers, err := s.apiClient().ContainerList(ctx, containerType.ListOptions{
+		Filters: filtersListNames,
+		All:     true,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	containersByIds, err := s.apiClient().ContainerList(ctx, containerType.ListOptions{
+		Filters: filtersListIDs,
+		All:     true,
+	})
+	if err != nil {
+		return nil, err
+	}
+	for _, container := range containersByIds {
+		if !utils.Contains(containers, container) {
+			containers = append(containers, container)
+		}
+	}
+
+	if len(containers) == 0 {
+		return nil, fmt.Errorf("no container(s) found with the following name(s): %s", strings.Join(options.Containers, ","))
+	}
+
+	return s.createProjectFromContainers(containers, options.ProjectName)
+}
+
+func (s *composeService) createProjectFromContainers(containers []moby.Container, projectName string) (*types.Project, error) {
+	project := &types.Project{}
+	services := types.Services{}
+	networks := types.Networks{}
+	volumes := types.Volumes{}
+	secrets := types.Secrets{}
+
+	if projectName != "" {
+		project.Name = projectName
+	}
+
+	for _, c := range containers {
+		// if the container is from a previous Compose application, use the existing service name
+		serviceLabel, ok := c.Labels[api.ServiceLabel]
+		if !ok {
+			serviceLabel = getCanonicalContainerName(c)
+		}
+		service, ok := services[serviceLabel]
+		if !ok {
+			service = types.ServiceConfig{
+				Name:   serviceLabel,
+				Image:  c.Image,
+				Labels: c.Labels,
+			}
+
+		}
+		service.Scale = increment(service.Scale)
+
+		inspect, err := s.apiClient().ContainerInspect(context.Background(), c.ID)
+		if err != nil {
+			services[serviceLabel] = service
+			continue
+		}
+		s.extractComposeConfiguration(&service, inspect, volumes, secrets, networks)
+		service.Labels = cleanDockerPreviousLabels(service.Labels)
+		services[serviceLabel] = service
+	}
+
+	project.Services = services
+	project.Networks = networks
+	project.Volumes = volumes
+	project.Secrets = secrets
+	return project, nil
+}
+
+func (s *composeService) extractComposeConfiguration(service *types.ServiceConfig, inspect moby.ContainerJSON, volumes types.Volumes, secrets types.Secrets, networks types.Networks) {
+	service.Environment = types.NewMappingWithEquals(inspect.Config.Env)
+	if inspect.Config.Healthcheck != nil {
+		healthConfig := inspect.Config.Healthcheck
+		service.HealthCheck = s.toComposeHealthCheck(healthConfig)
+	}
+	if len(inspect.Mounts) > 0 {
+		detectedVolumes, volumeConfigs, detectedSecrets, secretsConfigs := s.toComposeVolumes(inspect.Mounts)
+		service.Volumes = append(service.Volumes, volumeConfigs...)
+		service.Secrets = append(service.Secrets, secretsConfigs...)
+		maps.Copy(volumes, detectedVolumes)
+		maps.Copy(secrets, detectedSecrets)
+	}
+	if len(inspect.NetworkSettings.Networks) > 0 {
+		detectedNetworks, networkConfigs := s.toComposeNetwork(inspect.NetworkSettings.Networks)
+		service.Networks = networkConfigs
+		maps.Copy(networks, detectedNetworks)
+	}
+	if len(inspect.HostConfig.PortBindings) > 0 {
+		for key, portBindings := range inspect.HostConfig.PortBindings {
+			for _, portBinding := range portBindings {
+				service.Ports = append(service.Ports, types.ServicePortConfig{
+					Target:    uint32(key.Int()),
+					Published: portBinding.HostPort,
+					Protocol:  key.Proto(),
+					HostIP:    portBinding.HostIP,
+				})
+			}
+		}
+	}
+}
+
+func (s *composeService) toComposeHealthCheck(healthConfig *containerType.HealthConfig) *types.HealthCheckConfig {
+	var healthCheck types.HealthCheckConfig
+	healthCheck.Test = healthConfig.Test
+	if healthConfig.Timeout != 0 {
+		timeout := types.Duration(healthConfig.Timeout)
+		healthCheck.Timeout = &timeout
+	}
+	if healthConfig.Interval != 0 {
+		interval := types.Duration(healthConfig.Interval)
+		healthCheck.Interval = &interval
+	}
+	if healthConfig.StartPeriod != 0 {
+		startPeriod := types.Duration(healthConfig.StartPeriod)
+		healthCheck.StartPeriod = &startPeriod
+	}
+	if healthConfig.StartInterval != 0 {
+		startInterval := types.Duration(healthConfig.StartInterval)
+		healthCheck.StartInterval = &startInterval
+	}
+	if healthConfig.Retries != 0 {
+		retries := uint64(healthConfig.Retries)
+		healthCheck.Retries = &retries
+	}
+	return &healthCheck
+}
+
+func (s *composeService) toComposeVolumes(volumes []moby.MountPoint) (map[string]types.VolumeConfig,
+	[]types.ServiceVolumeConfig, map[string]types.SecretConfig, []types.ServiceSecretConfig) {
+	volumeConfigs := make(map[string]types.VolumeConfig)
+	secretConfigs := make(map[string]types.SecretConfig)
+	var serviceVolumeConfigs []types.ServiceVolumeConfig
+	var serviceSecretConfigs []types.ServiceSecretConfig
+
+	for _, volume := range volumes {
+		serviceVC := types.ServiceVolumeConfig{
+			Type:     string(volume.Type),
+			Source:   volume.Source,
+			Target:   volume.Destination,
+			ReadOnly: !volume.RW,
+		}
+		switch volume.Type {
+		case mount.TypeVolume:
+			serviceVC.Source = volume.Name
+			vol := types.VolumeConfig{}
+			if volume.Driver != "local" {
+				vol.Driver = volume.Driver
+				vol.Name = volume.Name
+			}
+			volumeConfigs[volume.Name] = vol
+			serviceVolumeConfigs = append(serviceVolumeConfigs, serviceVC)
+		case mount.TypeBind:
+			if strings.HasPrefix(volume.Destination, "/run/secrets") {
+				destination := strings.Split(volume.Destination, "/")
+				secret := types.SecretConfig{
+					Name: destination[len(destination)-1],
+					File: strings.TrimPrefix(volume.Source, "/host_mnt"),
+				}
+				secretConfigs[secret.Name] = secret
+				serviceSecretConfigs = append(serviceSecretConfigs, types.ServiceSecretConfig{
+					Source: secret.Name,
+					Target: volume.Destination,
+				})
+			} else {
+				serviceVolumeConfigs = append(serviceVolumeConfigs, serviceVC)
+			}
+		}
+	}
+	return volumeConfigs, serviceVolumeConfigs, secretConfigs, serviceSecretConfigs
+}
+
+func (s *composeService) toComposeNetwork(networks map[string]*network.EndpointSettings) (map[string]types.NetworkConfig, map[string]*types.ServiceNetworkConfig) {
+	networkConfigs := make(map[string]types.NetworkConfig)
+	serviceNetworkConfigs := make(map[string]*types.ServiceNetworkConfig)
+
+	for name, net := range networks {
+		inspect, err := s.apiClient().NetworkInspect(context.Background(), name, network.InspectOptions{})
+		if err != nil {
+			networkConfigs[name] = types.NetworkConfig{}
+		} else {
+			networkConfigs[name] = types.NetworkConfig{
+				Internal: inspect.Internal,
+			}
+
+		}
+		serviceNetworkConfigs[name] = &types.ServiceNetworkConfig{
+			Aliases: net.Aliases,
+		}
+	}
+	return networkConfigs, serviceNetworkConfigs
+}
+
+func cleanDockerPreviousLabels(labels types.Labels) types.Labels {
+	cleanedLabels := types.Labels{}
+	for key, value := range labels {
+		if !strings.HasPrefix(key, "com.docker.compose.") && !strings.HasPrefix(key, "desktop.docker.io") {
+			cleanedLabels[key] = value
+		}
+	}
+	return cleanedLabels
+}

+ 15 - 0
pkg/mocks/mock_docker_compose_api.go

@@ -169,6 +169,21 @@ func (mr *MockServiceMockRecorder) Export(ctx, projectName, options any) *gomock
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Export", reflect.TypeOf((*MockService)(nil).Export), ctx, projectName, options)
 }
 
+// Generate mocks base method.
+func (m *MockService) Generate(ctx context.Context, options api.GenerateOptions) (*types.Project, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Generate", ctx, options)
+	ret0, _ := ret[0].(*types.Project)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Generate indicates an expected call of Generate.
+func (mr *MockServiceMockRecorder) Generate(ctx, options any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockService)(nil).Generate), ctx, options)
+}
+
 // Images mocks base method.
 func (m *MockService) Images(ctx context.Context, projectName string, options api.ImagesOptions) ([]api.ImageSummary, error) {
 	m.ctrl.T.Helper()