introduce generate command as alpha command

Signed-off-by: Guillaume Lours <705411+glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2024-10-18 13:20:06 +02:00
parent fafaa9c5b8
commit 51ebeb5441
9 changed files with 430 additions and 1 deletions

View File

@ -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
cmd/compose/generate.go Normal file
View File

@ -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
}

View File

@ -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-->

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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
pkg/compose/generate.go Normal file
View File

@ -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
}

View File

@ -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()