From a14defd8023f388a6867a89fec532bf14c75b946 Mon Sep 17 00:00:00 2001 From: Guillaume Lours <705411+glours@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:20:06 +0200 Subject: [PATCH] introduce generate command as alpha command Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com> --- cmd/compose/alpha.go | 1 + cmd/compose/generate.go | 82 ++++++ docs/reference/compose_alpha_generate.md | 17 ++ docs/reference/docker_compose_alpha.yaml | 2 + .../docker_compose_alpha_generate.yaml | 53 ++++ pkg/api/api.go | 9 + pkg/compose/compose.go | 5 +- pkg/compose/generate.go | 247 ++++++++++++++++++ pkg/mocks/mock_docker_compose_api.go | 15 ++ 9 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 cmd/compose/generate.go create mode 100644 docs/reference/compose_alpha_generate.md create mode 100644 docs/reference/docker_compose_alpha_generate.yaml create mode 100644 pkg/compose/generate.go diff --git a/cmd/compose/alpha.go b/cmd/compose/alpha.go index 64a2c910f68..74636393e18 100644 --- a/cmd/compose/alpha.go +++ b/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 } diff --git a/cmd/compose/generate.go b/cmd/compose/generate.go new file mode 100644 index 00000000000..a0b32057d34 --- /dev/null +++ b/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 +} diff --git a/docs/reference/compose_alpha_generate.md b/docs/reference/compose_alpha_generate.md new file mode 100644 index 00000000000..f4054627798 --- /dev/null +++ b/docs/reference/compose_alpha_generate.md @@ -0,0 +1,17 @@ +# docker compose alpha generate + + +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 | + + + + diff --git a/docs/reference/docker_compose_alpha.yaml b/docs/reference/docker_compose_alpha.yaml index 807097a387e..e6b6b6e6b6f 100644 --- a/docs/reference/docker_compose_alpha.yaml +++ b/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: diff --git a/docs/reference/docker_compose_alpha_generate.yaml b/docs/reference/docker_compose_alpha_generate.yaml new file mode 100644 index 00000000000..0932af080ec --- /dev/null +++ b/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 + diff --git a/pkg/api/api.go b/pkg/api/api.go index e48cde46d39..949449c57cc 100644 --- a/pkg/api/api.go +++ b/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 { @@ -562,6 +564,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" diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 6bd57472804..fb0088428f5 100644 --- a/pkg/compose/compose.go +++ b/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{ diff --git a/pkg/compose/generate.go b/pkg/compose/generate.go new file mode 100644 index 00000000000..5f2d5436a5a --- /dev/null +++ b/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 +} diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 858c6e7b13a..8bd19b9067f 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/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()