Added tests to `viz` subcommand

Signed-off-by: Benjamín Guzmán <bg@benjaminguzman.dev>
This commit is contained in:
Benjamín Guzmán 2023-04-02 17:26:24 -06:00 committed by Nicolas De loof
parent 3751c3074b
commit 7840a92c40
8 changed files with 478 additions and 108 deletions

View File

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

View File

@ -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<<font point-size="15">service name</font>
graphBuilder.WriteString(opts.indentationStr)
writeQuoted(graphBuilder, serviceNode.Name)
graphBuilder.WriteString(" [style=\"filled\" label=<<font point-size=\"15\">")
graphBuilder.WriteString(serviceNode.Name)
graphBuilder.WriteString("</font>")
if opts.includeNetworks && len(serviceNode.Networks) > 0 {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Networks:</b>")
for _, networkName := range serviceNode.NetworksByPriority() {
graphBuilder.WriteString("<br/>")
graphBuilder.WriteString(networkName)
}
graphBuilder.WriteString("</font>")
}
if opts.includePorts && len(serviceNode.Ports) > 0 {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Ports:</b>")
for _, portConfig := range serviceNode.Ports {
graphBuilder.WriteString("<br/>")
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("</font>")
}
if opts.includeImageName {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Image:</b><br/>")
graphBuilder.WriteString(serviceNode.Image)
graphBuilder.WriteString("</font>")
}
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 {

92
cmd/compose/viz_test.go Normal file
View File

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

View File

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

View File

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

132
pkg/compose/viz.go Normal file
View File

@ -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<<font point-size="15">service name</font>
graphBuilder.WriteString(opts.Indentation)
writeQuoted(graphBuilder, serviceNode.Name)
graphBuilder.WriteString(" [style=\"filled\" label=<<font point-size=\"15\">")
graphBuilder.WriteString(serviceNode.Name)
graphBuilder.WriteString("</font>")
if opts.IncludeNetworks && len(serviceNode.Networks) > 0 {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Networks:</b>")
for _, networkName := range serviceNode.NetworksByPriority() {
graphBuilder.WriteString("<br/>")
graphBuilder.WriteString(networkName)
}
graphBuilder.WriteString("</font>")
}
if opts.IncludePorts && len(serviceNode.Ports) > 0 {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Ports:</b>")
for _, portConfig := range serviceNode.Ports {
graphBuilder.WriteString("<br/>")
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("</font>")
}
if opts.IncludeImageName {
graphBuilder.WriteString("<font point-size=\"10\">")
graphBuilder.WriteString("<br/><br/><b>Image:</b><br/>")
graphBuilder.WriteString(serviceNode.Image)
graphBuilder.WriteString("</font>")
}
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('"')
}

204
pkg/compose/viz_test.go Normal file
View File

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

View File

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