From 7840a92c40f71796643146f3d46ab4287c08385c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Guzm=C3=A1n?= Date: Sun, 2 Apr 2023 17:26:24 -0600 Subject: [PATCH] Added tests to `viz` subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Benjamín Guzmán --- cmd/compose/alpha.go | 2 +- cmd/compose/viz.go | 118 ++-------------- cmd/compose/viz_test.go | 92 ++++++++++++ pkg/api/api.go | 13 ++ pkg/api/proxy.go | 10 ++ pkg/compose/viz.go | 132 +++++++++++++++++ pkg/compose/viz_test.go | 204 +++++++++++++++++++++++++++ pkg/mocks/mock_docker_compose_api.go | 15 ++ 8 files changed, 478 insertions(+), 108 deletions(-) create mode 100644 cmd/compose/viz_test.go create mode 100644 pkg/compose/viz.go create mode 100644 pkg/compose/viz_test.go diff --git a/cmd/compose/alpha.go b/cmd/compose/alpha.go index cb4181cd5..a05210d51 100644 --- a/cmd/compose/alpha.go +++ b/cmd/compose/alpha.go @@ -34,7 +34,7 @@ func alphaCommand(p *ProjectOptions, backend api.Service) *cobra.Command { cmd.AddCommand( watchCommand(p, backend), dryRunRedirectCommand(p), - vizCommand(p), + vizCommand(p, backend), ) return cmd } diff --git a/cmd/compose/viz.go b/cmd/compose/viz.go index cb8fce12a..145340b7a 100644 --- a/cmd/compose/viz.go +++ b/cmd/compose/viz.go @@ -20,10 +20,9 @@ import ( "context" "fmt" "os" - "strconv" "strings" - "github.com/compose-spec/compose-go/types" + "github.com/docker/compose/v2/pkg/api" "github.com/spf13/cobra" ) @@ -35,10 +34,7 @@ type vizOptions struct { indentationStr string } -// maps a service with the services it depends on -type vizGraph map[*types.ServiceConfig][]*types.ServiceConfig - -func vizCommand(p *ProjectOptions) *cobra.Command { +func vizCommand(p *ProjectOptions, backend api.Service) *cobra.Command { opts := vizOptions{ ProjectOptions: p, } @@ -54,7 +50,7 @@ func vizCommand(p *ProjectOptions) *cobra.Command { return err }), RunE: Adapt(func(ctx context.Context, args []string) error { - return runViz(ctx, &opts) + return runViz(ctx, backend, &opts) }), } @@ -66,7 +62,7 @@ func vizCommand(p *ProjectOptions) *cobra.Command { return cmd } -func runViz(_ context.Context, opts *vizOptions) error { +func runViz(ctx context.Context, backend api.Service, opts *vizOptions) error { _, _ = fmt.Fprintln(os.Stderr, "viz command is EXPERIMENTAL") project, err := opts.ToProject(nil) if err != nil { @@ -74,110 +70,18 @@ func runViz(_ context.Context, opts *vizOptions) error { } // build graph - graph := make(vizGraph) - for i, serviceConfig := range project.Services { - serviceConfigPtr := &project.Services[i] - graph[serviceConfigPtr] = make([]*types.ServiceConfig, 0, len(serviceConfig.DependsOn)) - for dependencyName := range serviceConfig.DependsOn { - // no error should be returned since dependencyName should exist - dependency, _ := project.GetService(dependencyName) - graph[serviceConfigPtr] = append(graph[serviceConfigPtr], &dependency) - } - } + graphStr, _ := backend.Viz(ctx, project, api.VizOptions{ + IncludeNetworks: opts.includeNetworks, + IncludePorts: opts.includePorts, + IncludeImageName: opts.includeImageName, + Indentation: opts.indentationStr, + }) - // build graphviz graph - var graphBuilder strings.Builder - graphBuilder.WriteString("digraph " + project.Name + " {\n") - graphBuilder.WriteString(opts.indentationStr + "layout=dot;\n") - addNodes(&graphBuilder, graph, opts) - graphBuilder.WriteByte('\n') - addEdges(&graphBuilder, graph, opts) - graphBuilder.WriteString("}\n") - - fmt.Println(graphBuilder.String()) + fmt.Println(graphStr) return nil } -// addNodes adds the corresponding graphviz representation of all the nodes in the given graph to the graphBuilder -// returns the same graphBuilder -func addNodes(graphBuilder *strings.Builder, graph vizGraph, opts *vizOptions) *strings.Builder { - for serviceNode := range graph { - // write: - // "service name" [style="filled" label<service name - graphBuilder.WriteString(opts.indentationStr) - writeQuoted(graphBuilder, serviceNode.Name) - graphBuilder.WriteString(" [style=\"filled\" label=<") - graphBuilder.WriteString(serviceNode.Name) - graphBuilder.WriteString("") - - if opts.includeNetworks && len(serviceNode.Networks) > 0 { - graphBuilder.WriteString("") - graphBuilder.WriteString("

Networks:") - for _, networkName := range serviceNode.NetworksByPriority() { - graphBuilder.WriteString("
") - graphBuilder.WriteString(networkName) - } - graphBuilder.WriteString("
") - } - - if opts.includePorts && len(serviceNode.Ports) > 0 { - graphBuilder.WriteString("") - graphBuilder.WriteString("

Ports:") - for _, portConfig := range serviceNode.Ports { - graphBuilder.WriteString("
") - if len(portConfig.HostIP) > 0 { - graphBuilder.WriteString(portConfig.HostIP) - graphBuilder.WriteByte(':') - } - graphBuilder.WriteString(portConfig.Published) - graphBuilder.WriteByte(':') - graphBuilder.WriteString(strconv.Itoa(int(portConfig.Target))) - graphBuilder.WriteString(" (") - graphBuilder.WriteString(portConfig.Protocol) - graphBuilder.WriteString(", ") - graphBuilder.WriteString(portConfig.Mode) - graphBuilder.WriteString(")") - } - graphBuilder.WriteString("
") - } - - if opts.includeImageName { - graphBuilder.WriteString("") - graphBuilder.WriteString("

Image:
") - graphBuilder.WriteString(serviceNode.Image) - graphBuilder.WriteString("
") - } - - graphBuilder.WriteString(">];\n") - } - - return graphBuilder -} - -// addEdges adds the corresponding graphviz representation of all edges in the given graph to the graphBuilder -// returns the same graphBuilder -func addEdges(graphBuilder *strings.Builder, graph vizGraph, opts *vizOptions) *strings.Builder { - for parent, children := range graph { - for _, child := range children { - graphBuilder.WriteString(opts.indentationStr) - writeQuoted(graphBuilder, parent.Name) - graphBuilder.WriteString(" -> ") - writeQuoted(graphBuilder, child.Name) - graphBuilder.WriteString(";\n") - } - } - - return graphBuilder -} - -// writeQuoted writes "str" to builder -func writeQuoted(builder *strings.Builder, str string) { - builder.WriteByte('"') - builder.WriteString(str) - builder.WriteByte('"') -} - // preferredIndentationStr returns a single string given the indentation preference func preferredIndentationStr(size int, useSpace bool) (string, error) { if size < 0 { diff --git a/cmd/compose/viz_test.go b/cmd/compose/viz_test.go new file mode 100644 index 000000000..9691e1db3 --- /dev/null +++ b/cmd/compose/viz_test.go @@ -0,0 +1,92 @@ +/* + Copyright 2020 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 ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPreferredIndentationStr(t *testing.T) { + type args struct { + size int + useSpace bool + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "should return '\\t\\t'", + args: args{ + size: 2, + useSpace: false, + }, + want: "\t\t", + wantErr: false, + }, + { + name: "should return ' '", + args: args{ + size: 4, + useSpace: true, + }, + want: " ", + wantErr: false, + }, + { + name: "should return ''", + args: args{ + size: 0, + useSpace: false, + }, + want: "", + wantErr: false, + }, + { + name: "should return ''", + args: args{ + size: 0, + useSpace: true, + }, + want: "", + wantErr: false, + }, + { + name: "should throw error because indentation size < 0", + args: args{ + size: -1, + useSpace: false, + }, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := preferredIndentationStr(tt.args.size, tt.args.useSpace) + if tt.wantErr && assert.NotNilf(t, err, fmt.Sprintf("preferredIndentationStr(%v, %v)", tt.args.size, tt.args.useSpace)) { + return + } + assert.Equalf(t, tt.want, got, "preferredIndentationStr(%v, %v)", tt.args.size, tt.args.useSpace) + }) + } +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 1a6f7e464..868bf1165 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -82,6 +82,19 @@ type Service interface { DryRunMode(ctx context.Context, dryRun bool) (context.Context, error) // Watch services' development context and sync/notify/rebuild/restart on changes Watch(ctx context.Context, project *types.Project, services []string, options WatchOptions) error + // Viz generates a graphviz graph of the project services + Viz(ctx context.Context, project *types.Project, options VizOptions) (string, error) +} + +type VizOptions struct { + // IncludeNetworks if true, network names a container is attached to should appear in the graph node + IncludeNetworks bool + // IncludePorts if true, ports a container exposes should appear in the graph node + IncludePorts bool + // IncludeImageName if true, name of the image used to create a container should appear in the graph node + IncludeImageName bool + // Indentation string to be used to indent graphviz code, e.g. "\t", " " + Indentation string } // WatchOptions group options of the Watch API diff --git a/pkg/api/proxy.go b/pkg/api/proxy.go index 621bce79a..30f9aa273 100644 --- a/pkg/api/proxy.go +++ b/pkg/api/proxy.go @@ -53,6 +53,7 @@ type ServiceProxy struct { WatchFn func(ctx context.Context, project *types.Project, services []string, options WatchOptions) error MaxConcurrencyFn func(parallel int) DryRunModeFn func(ctx context.Context, dryRun bool) (context.Context, error) + VizFn func(ctx context.Context, project *types.Project, options VizOptions) (string, error) interceptors []Interceptor } @@ -93,6 +94,7 @@ func (s *ServiceProxy) WithService(service Service) *ServiceProxy { s.WatchFn = service.Watch s.MaxConcurrencyFn = service.MaxConcurrency s.DryRunModeFn = service.DryRunMode + s.VizFn = service.Viz return s } @@ -323,6 +325,14 @@ func (s *ServiceProxy) Watch(ctx context.Context, project *types.Project, servic return s.WatchFn(ctx, project, services, options) } +// Viz implements Viz interface +func (s *ServiceProxy) Viz(ctx context.Context, project *types.Project, options VizOptions) (string, error) { + if s.VizFn == nil { + return "", ErrNotImplemented + } + return s.VizFn(ctx, project, options) +} + func (s *ServiceProxy) MaxConcurrency(i int) { s.MaxConcurrencyFn(i) } diff --git a/pkg/compose/viz.go b/pkg/compose/viz.go new file mode 100644 index 000000000..4d932ee44 --- /dev/null +++ b/pkg/compose/viz.go @@ -0,0 +1,132 @@ +/* + 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" + "strconv" + "strings" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose/v2/pkg/api" +) + +// maps a service with the services it depends on +type vizGraph map[*types.ServiceConfig][]*types.ServiceConfig + +func (s *composeService) Viz(_ context.Context, project *types.Project, opts api.VizOptions) (string, error) { + graph := make(vizGraph) + for i, serviceConfig := range project.Services { + serviceConfigPtr := &project.Services[i] + graph[serviceConfigPtr] = make([]*types.ServiceConfig, 0, len(serviceConfig.DependsOn)) + for dependencyName := range serviceConfig.DependsOn { + // no error should be returned since dependencyName should exist + dependency, _ := project.GetService(dependencyName) + graph[serviceConfigPtr] = append(graph[serviceConfigPtr], &dependency) + } + } + + // build graphviz graph + var graphBuilder strings.Builder + graphBuilder.WriteString("digraph " + project.Name + " {\n") + graphBuilder.WriteString(opts.Indentation + "layout=dot;\n") + addNodes(&graphBuilder, graph, &opts) + graphBuilder.WriteByte('\n') + addEdges(&graphBuilder, graph, &opts) + graphBuilder.WriteString("}\n") + + return graphBuilder.String(), nil +} + +// addNodes adds the corresponding graphviz representation of all the nodes in the given graph to the graphBuilder +// returns the same graphBuilder +func addNodes(graphBuilder *strings.Builder, graph vizGraph, opts *api.VizOptions) *strings.Builder { + for serviceNode := range graph { + // write: + // "service name" [style="filled" label<service name + graphBuilder.WriteString(opts.Indentation) + writeQuoted(graphBuilder, serviceNode.Name) + graphBuilder.WriteString(" [style=\"filled\" label=<") + graphBuilder.WriteString(serviceNode.Name) + graphBuilder.WriteString("") + + if opts.IncludeNetworks && len(serviceNode.Networks) > 0 { + graphBuilder.WriteString("") + graphBuilder.WriteString("

Networks:") + for _, networkName := range serviceNode.NetworksByPriority() { + graphBuilder.WriteString("
") + graphBuilder.WriteString(networkName) + } + graphBuilder.WriteString("
") + } + + if opts.IncludePorts && len(serviceNode.Ports) > 0 { + graphBuilder.WriteString("") + graphBuilder.WriteString("

Ports:") + for _, portConfig := range serviceNode.Ports { + graphBuilder.WriteString("
") + if len(portConfig.HostIP) > 0 { + graphBuilder.WriteString(portConfig.HostIP) + graphBuilder.WriteByte(':') + } + graphBuilder.WriteString(portConfig.Published) + graphBuilder.WriteByte(':') + graphBuilder.WriteString(strconv.Itoa(int(portConfig.Target))) + graphBuilder.WriteString(" (") + graphBuilder.WriteString(portConfig.Protocol) + graphBuilder.WriteString(", ") + graphBuilder.WriteString(portConfig.Mode) + graphBuilder.WriteString(")") + } + graphBuilder.WriteString("
") + } + + if opts.IncludeImageName { + graphBuilder.WriteString("") + graphBuilder.WriteString("

Image:
") + graphBuilder.WriteString(serviceNode.Image) + graphBuilder.WriteString("
") + } + + graphBuilder.WriteString(">];\n") + } + + return graphBuilder +} + +// addEdges adds the corresponding graphviz representation of all edges in the given graph to the graphBuilder +// returns the same graphBuilder +func addEdges(graphBuilder *strings.Builder, graph vizGraph, opts *api.VizOptions) *strings.Builder { + for parent, children := range graph { + for _, child := range children { + graphBuilder.WriteString(opts.Indentation) + writeQuoted(graphBuilder, parent.Name) + graphBuilder.WriteString(" -> ") + writeQuoted(graphBuilder, child.Name) + graphBuilder.WriteString(";\n") + } + } + + return graphBuilder +} + +// writeQuoted writes "str" to builder +func writeQuoted(builder *strings.Builder, str string) { + builder.WriteByte('"') + builder.WriteString(str) + builder.WriteByte('"') +} diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go new file mode 100644 index 000000000..15425b904 --- /dev/null +++ b/pkg/compose/viz_test.go @@ -0,0 +1,204 @@ +/* + Copyright 2020 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" + "strconv" + "testing" + + "github.com/compose-spec/compose-go/types" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + compose "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/mocks" +) + +func TestViz(t *testing.T) { + project := types.Project{ + Name: "viz-test", + WorkingDir: "/home", + Services: []types.ServiceConfig{ + { + Name: "service1", + Image: "image-for-service1", + Ports: []types.ServicePortConfig{ + { + Published: "80", + Target: 80, + Protocol: "tcp", + }, + { + Published: "53", + Target: 533, + Protocol: "udp", + }, + }, + Networks: map[string]*types.ServiceNetworkConfig{ + "internal": nil, + }, + }, + { + Name: "service2", + Image: "image-for-service2", + Ports: []types.ServicePortConfig{}, + }, + { + Name: "service3", + Image: "some-image", + DependsOn: map[string]types.ServiceDependency{ + "service2": {}, + "service1": {}, + }, + }, + { + Name: "service4", + Image: "another-image", + DependsOn: map[string]types.ServiceDependency{ + "service3": {}, + }, + Ports: []types.ServicePortConfig{ + { + Published: "8080", + Target: 80, + }, + }, + Networks: map[string]*types.ServiceNetworkConfig{ + "external": nil, + }, + }, + }, + Networks: types.Networks{ + "internal": types.NetworkConfig{}, + "external": types.NetworkConfig{}, + "not-used": types.NetworkConfig{}, + }, + Volumes: nil, + Secrets: nil, + Configs: nil, + Extensions: nil, + ComposeFiles: nil, + Environment: nil, + DisabledServices: nil, + Profiles: nil, + } + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + cli := mocks.NewMockCli(mockCtrl) + tested := composeService{ + dockerCli: cli, + } + + ctx := context.Background() + + t.Run("viz (no ports, networks or image)", func(t *testing.T) { + graphStr, err := tested.Viz(ctx, &project, compose.VizOptions{ + Indentation: " ", + IncludePorts: false, + IncludeImageName: false, + IncludeNetworks: false, + }) + assert.NoError(t, err, "viz command failed") + + // check indentation + assert.Contains(t, graphStr, "\n ", graphStr) + assert.NotContains(t, graphStr, "\n ", graphStr) + + // check digraph name + assert.Contains(t, graphStr, "digraph "+project.Name, graphStr) + + // check nodes + for _, service := range project.Services { + assert.Contains(t, graphStr, "\""+service.Name+"\" [style=\"filled\"", graphStr) + } + + // check node attributes + assert.NotContains(t, graphStr, "Networks", graphStr) + assert.NotContains(t, graphStr, "Image", graphStr) + assert.NotContains(t, graphStr, "Ports", graphStr) + + // check edges that SHOULD exist in the generated graph + allowedEdges := make(map[string][]string) + for _, service := range project.Services { + allowedEdges[service.Name] = make([]string, 0, len(service.DependsOn)) + for depName := range service.DependsOn { + allowedEdges[service.Name] = append(allowedEdges[service.Name], depName) + } + } + for serviceName, dependencies := range allowedEdges { + for _, dependencyName := range dependencies { + assert.Contains(t, graphStr, "\""+serviceName+"\" -> \""+dependencyName+"\"", graphStr) + } + } + + // check edges that SHOULD NOT exist in the generated graph + forbiddenEdges := make(map[string][]string) + for _, service := range project.Services { + forbiddenEdges[service.Name] = make([]string, 0, len(project.ServiceNames())-len(service.DependsOn)) + for _, serviceName := range project.ServiceNames() { + _, edgeExists := service.DependsOn[serviceName] + if !edgeExists { + forbiddenEdges[service.Name] = append(forbiddenEdges[service.Name], serviceName) + } + } + } + for serviceName, forbiddenDeps := range forbiddenEdges { + for _, forbiddenDep := range forbiddenDeps { + assert.NotContains(t, graphStr, "\""+serviceName+"\" -> \""+forbiddenDep+"\"") + } + } + }) + + t.Run("viz (with ports, networks and image)", func(t *testing.T) { + graphStr, err := tested.Viz(ctx, &project, compose.VizOptions{ + Indentation: "\t", + IncludePorts: true, + IncludeImageName: true, + IncludeNetworks: true, + }) + assert.NoError(t, err, "viz command failed") + + // check indentation + assert.Contains(t, graphStr, "\n\t", graphStr) + assert.NotContains(t, graphStr, "\n\t\t", graphStr) + + // check digraph name + assert.Contains(t, graphStr, "digraph "+project.Name, graphStr) + + // check nodes + for _, service := range project.Services { + assert.Contains(t, graphStr, "\""+service.Name+"\" [style=\"filled\"", graphStr) + } + + // check node attributes + assert.Contains(t, graphStr, "Networks", graphStr) + assert.Contains(t, graphStr, ">internal<", graphStr) + assert.Contains(t, graphStr, ">external<", graphStr) + assert.Contains(t, graphStr, "Image", graphStr) + for _, service := range project.Services { + assert.Contains(t, graphStr, ">"+service.Image+"<", graphStr) + } + assert.Contains(t, graphStr, "Ports", graphStr) + for _, service := range project.Services { + for _, portConfig := range service.Ports { + assert.NotContains(t, graphStr, ">"+portConfig.Published+":"+strconv.Itoa(int(portConfig.Target))+"<", graphStr) + } + } + }) +} diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 205f39557..120affd98 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -408,6 +408,21 @@ func (mr *MockServiceMockRecorder) Up(ctx, project, options interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockService)(nil).Up), ctx, project, options) } +// Viz mocks base method. +func (m *MockService) Viz(ctx context.Context, project *types.Project, options api.VizOptions) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Viz", ctx, project, options) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Viz indicates an expected call of Viz. +func (mr *MockServiceMockRecorder) Viz(ctx, project, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Viz", reflect.TypeOf((*MockService)(nil).Viz), ctx, project, options) +} + // Watch mocks base method. func (m *MockService) Watch(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error { m.ctrl.T.Helper()