diff --git a/.gitignore b/.gitignore index 3b84489..3b9bafd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ dist/ vendor/ +.structurizr/ +workspace.* +/*.json *.out *.png *.svg diff --git a/README.md b/README.md index baec945..f7de338 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Takes all network connections from your docker containers and exports them as: - [graphviz dot](https://www.graphviz.org/doc/info/lang.html) - [structurizr dsl](https://github.com/structurizr/dsl) - [compose yaml](https://github.com/compose-spec/compose-spec/blob/master/spec.md) +- [plant uml](https://github.com/plantuml/plantuml) - pseudographical tree - json stream - statistics - nodes, connections and listen ports counts @@ -53,6 +54,7 @@ Closest analogs, i can find, that not suit my needs very well: - all output formats are sorted, thus can be placed to any `vcs` to observe changes - fast, scans ~470 containers with ~4000 connections in around 5 sec - auto-clusterization based on graph topology +- deep inspection mode, in wich connections between procesess inside containers, also collected and shown - 100% test-coverage ## known limitations @@ -74,12 +76,14 @@ decompose [flags] -cluster string json file with clusterization rules, or auto: for auto-clustering, similarity is float in (0.0, 1.0] range +-compress + compress graph -deep process-based introspection -follow string follow only this container by name(s), comma-separated or from @file -format string - output format: json, csv, dot, yaml, stat, tree or sdsl for structurizr dsl (default "json") + output format: csv, dot, json, puml, sdsl, stat, tree, yaml (default "json") -full extract full process info: (cmd, args, env) and volumes info -help @@ -122,8 +126,12 @@ type Item struct { Cmd []string `json:"cmd"` Env []string `json:"env"` Labels map[string]string `json:"labels"` - } `json:"container"` // conatiner info - Listen map[string][]string `json:"listen"` // ports with process names + } `json:"container"` // container info + Listen map[string][]{ + Kind string `json:"kind"` // tcp / udp + Value int `json:"value"` + Local bool `json:"local"` // bound to loopback + } `json:"listen"` // ports with process names Networks []string `json:"networks"` // network names Tags []string `json:"tags"` // tags, if meta presents Volumes []*struct{ @@ -152,7 +160,9 @@ Single node example with full info and metadata filled: ], "labels": {} }, - "listen": {"foo": ["80/tcp"]}, + "listen": {"foo": [ + {"kind": "tcp", "value": 80} + ]}, "networks": ["test-net"], "tags": ["some"], "volumes": [ @@ -168,7 +178,9 @@ Single node example with full info and metadata filled: } ], "connected": { - "bar-1": ["443/tcp"] + "bar-1": [ + {"src": "foo", "dst": "[remote]", "port": {"kind": "tcp", "value": 443}} + ] } } ``` diff --git a/SECURITY.md b/SECURITY.md index 34dc395..b2eaf99 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 1.9.x | :white_check_mark: | -| \< 1.9 | :x: | +| 1.10.x | :white_check_mark: | +| \< 1.10 | :x: | ## Reporting a Vulnerability diff --git a/cmd/decompose/main.go b/cmd/decompose/main.go index 9451d7b..95ad8dd 100644 --- a/cmd/decompose/main.go +++ b/cmd/decompose/main.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" @@ -44,14 +45,15 @@ var ( fSilent, fVersion bool fHelp, fLocal bool fFull, fNoLoops bool - fDeep bool + fDeep, fCompress bool fProto, fFormat string fOut, fFollow string fMeta, fCluster string fSkipEnv string fLoad []string - ErrUnknown = errors.New("unknown") + knownBuilders string + ErrUnknown = errors.New("unknown") ) func version() string { @@ -85,6 +87,8 @@ func setupFlags() { flag.BoolVar(&fFull, "full", false, "extract full process info: (cmd, args, env) and volumes info") flag.BoolVar(&fNoLoops, "no-loops", false, "remove connection loops (node to itself) from output") flag.BoolVar(&fDeep, "deep", false, "process-based introspection") + flag.BoolVar(&fCompress, "compress", false, "compress graph") + flag.StringVar(&fOut, "out", defaultOutput, "output: filename or \"-\" for stdout") flag.StringVar(&fMeta, "meta", "", "json file with metadata for enrichment") flag.StringVar(&fProto, "proto", defaultProto, "protocol to scan: tcp, udp or all") @@ -97,12 +101,8 @@ func setupFlags() { "similarity is float in (0.0, 1.0] range", ) - flag.StringVar( - &fFormat, - "format", - builder.KindJSON, - "output format: json, csv, dot, yaml, stat, tree or sdsl for structurizr dsl", - ) + flag.StringVar(&fFormat, "format", builder.KindJSON, "output format: "+knownBuilders) + flag.StringVar( &fSkipEnv, "skip-env", @@ -262,7 +262,7 @@ func prepareConfig() ( "%w format: %s known: %s", ErrUnknown, fFormat, - strings.Join(builder.Names(), ","), + knownBuilders, ) } @@ -290,6 +290,12 @@ func prepareConfig() ( bildr, nwr = cb, cb } + if fCompress { + cmp := graph.NewCompressor(bildr) + + bildr, nwr = cmp, cmp + } + skipKeys := []string{} if fSkipEnv != "" { @@ -396,6 +402,9 @@ func doBuild( } func main() { + slices.Sort(builder.Names) + knownBuilders = strings.Join(builder.Names, ", ") + setupFlags() flag.Parse() diff --git a/examples/stream.json b/examples/stream.json index 3b66a74..d2174b3 100644 --- a/examples/stream.json +++ b/examples/stream.json @@ -1,40 +1,45 @@ { - "name": "nginx-1", - "is_external": false, - "listen": {"nginx": ["80/tcp"]}, + "name": "nginx1", + "listen": {"nginx": [{"kind": "tcp", "value": 80}]}, "connected": { - "back-1": [{"src": "nginx", "dst": "app", "port": "8080/tcp"}], - "back-2": [{"src": "nginx", "dst": "app", "port": "8081/tcp"}] + "back1": [{"src": "nginx", "dst": "app", "port": {"kind": "tcp", "value": 8080}}], + "back2": [{"src": "nginx", "dst": "app", "port": {"kind": "tcp", "value": 8081}}] } } { - "name": "db-1", - "is_external": false, - "listen": {"postgres": ["5432/tcp"]}, + "name": "db1", + "listen": {"postgres": [{"kind": "tcp", "value": 5432}]}, "connected": {} } { - "name": "back-1", - "is_external": false, - "listen": {"app": ["8080/tcp", "8081/tcp", "9000/tcp"]}, + "name": "back1", + "listen": {"app": [ + {"kind": "tcp", "value": 8080}, + {"kind": "tcp", "value": 8081}, + {"kind": "tcp", "value": 9000} + ]}, "connected": { - "db-1": [{"src": "app", "dst": "postgres", "port": "5432/tcp"}] + "db1": [{"src": "app", "dst": "postgres", "port": {"kind": "tcp", "value": 5432}}] } } { - "name": "back-2", - "is_external": false, - "listen": {"app": ["8080/tcp", "8081/tcp"]}, + "name": "back2", + "listen": {"app": [ + {"kind": "tcp", "value": 8080}, + {"kind": "tcp", "value": 8081} + ]}, "connected": { - "db-1": [{"src": "app", "dst": "postgres", "port": "5432/tcp"}], - "foo-1": [{"src": "app", "dst": "[remote]", "port": "9500/tcp"}] + "db1": [{"src": "app", "dst": "postgres", "port": {"kind": "tcp", "value": 5432}}], + "foo1": [{"src": "app", "dst": "[remote]", "port": {"kind": "tcp", "value": 9500}}] } } { - "name": "foo-1", - "is_external": false, - "listen": {"[remote]": ["9500/tcp"]}, + "name": "foo1", + "is_external": true, + "listen": {"[remote]": [{"kind": "tcp", "value": 9500}]}, "connected": { - "back-1": [{"src": "[remote]", "dst": "app", "port": "9000/tcp"}] + "back1": [ + {"src": "[remote]", "dst": "app", "port": {"kind": "tcp", "value": 9000}} + ] } } diff --git a/go.mod b/go.mod index b48b949..fbe04d4 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/s0rg/decompose go 1.22 require ( - github.com/docker/docker v25.0.3+incompatible + github.com/docker/docker v25.0.4+incompatible github.com/emicklei/dot v1.6.1 github.com/expr-lang/expr v1.16.1 - github.com/prometheus/procfs v0.12.0 + github.com/prometheus/procfs v0.13.0 github.com/s0rg/set v1.2.0 github.com/s0rg/trie v1.3.0 gopkg.in/yaml.v3 v3.0.1 @@ -34,10 +34,10 @@ require ( go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/sdk v1.22.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/mod v0.15.0 // indirect - golang.org/x/sys v0.17.0 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.18.0 // indirect + golang.org/x/tools v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gotest.tools/v3 v3.5.0 // indirect ) diff --git a/go.sum b/go.sum index 9f3da58..fcf5158 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ= -github.com/docker/docker v25.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v25.0.4+incompatible h1:XITZTrq+52tZyZxUOtFIahUf3aH367FLxJzt9vZeAF8= +github.com/docker/docker v25.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -58,8 +58,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= +github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/s0rg/set v1.2.0 h1:53b207YMktNQJXYei/oHuTR5oOO2e9+eieZOncYsh9g= @@ -93,14 +93,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -109,8 +109,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= @@ -121,8 +121,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= -golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 74ebc23..20b67ad 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -12,8 +12,20 @@ const ( KindYAML = "yaml" KindSTAT = "stat" KindStructurizr = "sdsl" + KindPlantUML = "puml" ) +var Names = []string{ + KindCSV, + KindDOT, + KindJSON, + KindTREE, + KindYAML, + KindSTAT, + KindStructurizr, + KindPlantUML, +} + func Create(kind string) (b graph.NamedBuilderWriter, ok bool) { switch kind { case KindCSV: @@ -30,6 +42,8 @@ func Create(kind string) (b graph.NamedBuilderWriter, ok bool) { return NewYAML(), true case KindSTAT: return NewStat(), true + case KindPlantUML: + return NewPlantUML(), true } return @@ -37,21 +51,9 @@ func Create(kind string) (b graph.NamedBuilderWriter, ok bool) { func SupportCluster(n string) (yes bool) { switch n { - case KindDOT, KindStructurizr, KindSTAT: + case KindDOT, KindStructurizr, KindSTAT, KindPlantUML: return true } return false } - -func Names() (rv []string) { - return []string{ - KindCSV, - KindDOT, - KindJSON, - KindTREE, - KindYAML, - KindSTAT, - KindStructurizr, - } -} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index a83bb42..adc425a 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -13,7 +13,7 @@ func TestCreate(t *testing.T) { t.Fail() } - for _, name := range builder.Names() { + for _, name := range builder.Names { if _, ok := builder.Create(name); !ok { t.Fail() } @@ -23,8 +23,18 @@ func TestCreate(t *testing.T) { func TestSupportCluster(t *testing.T) { t.Parallel() - does := []string{builder.KindDOT, builder.KindSTAT, builder.KindStructurizr} - doesnt := []string{builder.KindJSON, builder.KindTREE, builder.KindYAML} + does := []string{ + builder.KindDOT, + builder.KindSTAT, + builder.KindStructurizr, + builder.KindPlantUML, + } + + doesnt := []string{ + builder.KindJSON, + builder.KindTREE, + builder.KindYAML, + } for _, k := range does { if !builder.SupportCluster(k) { diff --git a/internal/builder/common.go b/internal/builder/common.go new file mode 100644 index 0000000..a2a9b9e --- /dev/null +++ b/internal/builder/common.go @@ -0,0 +1,34 @@ +package builder + +import ( + "slices" + "strings" + + "github.com/s0rg/decompose/internal/node" +) + +func joinConnections(conns []*node.Connection, sep string) (rv string) { + raw := make([]string, 0, len(conns)) + + for _, c := range conns { + raw = append(raw, c.Port.Label()) + } + + slices.Sort(raw) + + return strings.Join(raw, sep) +} + +func joinListeners(ports map[string][]*node.Port, sep string) (rv string) { + var tmp []string + + for _, plist := range ports { + for _, p := range plist { + tmp = append(tmp, p.Label()) + } + } + + slices.Sort(tmp) + + return strings.Join(tmp, sep) +} diff --git a/internal/builder/csv.go b/internal/builder/csv.go index 6d6a6f0..8ae04f8 100644 --- a/internal/builder/csv.go +++ b/internal/builder/csv.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "fmt" "io" + "slices" "strings" "github.com/s0rg/decompose/internal/node" @@ -61,7 +62,17 @@ func (c *CSV) Write(w io.Writer) error { func renderOutbounds(conns map[string][]*node.Connection) (rv string) { var b strings.Builder - for k, v := range conns { + order := make([]string, 0, len(conns)) + + for k := range conns { + order = append(order, k) + } + + slices.Sort(order) + + for _, k := range order { + v := conns[k] + b.WriteString(k) b.WriteString(": ") b.WriteString(joinConnections(v, "; ")) diff --git a/internal/builder/dot.go b/internal/builder/dot.go index 9b1039d..877a893 100644 --- a/internal/builder/dot.go +++ b/internal/builder/dot.go @@ -30,6 +30,7 @@ var colors = []string{ type DOT struct { g *dot.Graph clusters map[string]*dot.Graph + edges map[string]map[string][]string } func NewDOT() *DOT { @@ -41,6 +42,7 @@ func NewDOT() *DOT { return &DOT{ g: g, clusters: make(map[string]*dot.Graph), + edges: make(map[string]map[string][]string), } } @@ -49,26 +51,6 @@ func (d *DOT) Name() string { } func (d *DOT) AddNode(n *node.Node) error { - var label, color string - - if n.IsExternal() { - color = "red" - label = "external: " + n.Name - } else { - color = "black" - label = n.Name + dotLF + "image: " + n.Image + dotLF + "net: " + strings.Join(n.Networks, ", ") - } - - if n.Meta != nil { - if lines, ok := n.FormatMeta(); ok { - label += dotLF + "info:" + dotLF + strings.Join(lines, dotLF) - } - - if len(n.Meta.Tags) > 0 { - label += dotLF + "tags: " + strings.Join(n.Meta.Tags, ",") - } - } - g := d.g if n.Cluster != "" { @@ -82,20 +64,23 @@ func (d *DOT) AddNode(n *node.Node) error { g = sg } + label, color := renderNode(n) + rb := g.Node(n.ID).Attr( "color", color, ).NewRecordBuilder() rb.FieldWithId(label, outPort) - rb.Nesting(func() { - n.Ports.Iter(func(_ string, plist []*node.Port) { - for _, p := range plist { - rb.FieldWithId(p.Label(), p.ID()) - } + if n.Ports.Len() > 0 { + rb.Nesting(func() { + n.Ports.Iter(func(process string, _ []*node.Port) { + rb.FieldWithId(process, portID(n.ID, process)) + }) }) - }) + } + // this cannot return error, thus error case cannot be tested :( _ = rb.Build() return nil @@ -118,23 +103,27 @@ func (d *DOT) getSrc(id string) (rv dot.Node, out string, ok bool) { return rv, out, ok } -func (d *DOT) getDst(id string, port *node.Port) (rv dot.Node, out string, ok bool) { - if rv, ok = d.g.FindNodeById(id); ok { - return rv, port.ID(), ok +func (d *DOT) getDst(edge *node.Edge) (rv dot.Node, out string, ok bool) { + dstID := portID(edge.DstID, edge.DstName) + + if rv, ok = d.g.FindNodeById(dstID); ok { + return rv, dstID, ok } - sg, ok := d.clusters[id] + if rv, ok = d.g.FindNodeById(edge.DstID); ok { + return rv, edge.DstID, ok + } + + sg, ok := d.clusters[edge.DstID] if !ok { return } - out = id + "_" + port.ID() - - if rv, ok = sg.FindNodeById(out); ok { - return rv, out, ok + if rv, ok = sg.FindNodeById(dstID); ok { + return rv, dstID, ok } - return sg.Node(out).Label(port.Label()), out, true + return sg.Node(out).Label(edge.Port.Label()), out, true } func (d *DOT) AddEdge(e *node.Edge) { @@ -147,7 +136,7 @@ func (d *DOT) AddEdge(e *node.Edge) { return } - dst, dstPort, ok := d.getDst(e.DstID, e.Port) + dst, dstPort, ok := d.getDst(e) if !ok { return } @@ -185,3 +174,51 @@ func labelColor(label string) (rv string) { return colors[hash%len(colors)] } + +func portID(id, name string) (rv string) { + return "port_" + id + "_" + name +} + +func renderNode(n *node.Node) (label, color string) { + var sb strings.Builder + + if n.IsExternal() { + color = "gray" + + sb.WriteString("external: ") + } else { + color = "black" + } + + sb.WriteString(n.Name) + sb.WriteString(dotLF) + + if n.Image != "" { + sb.WriteString("image: ") + sb.WriteString(n.Image) + sb.WriteString(dotLF) + } + + if len(n.Networks) > 0 { + sb.WriteString("nets: ") + sb.WriteString(strings.Join(n.Networks, ", ")) + sb.WriteString(dotLF) + } + + if n.Meta != nil { + if lines, ok := n.FormatMeta(); ok { + sb.WriteString("meta: ") + sb.WriteString(dotLF) + sb.WriteString(strings.Join(lines, dotLF)) + sb.WriteString(dotLF) + } + + if len(n.Meta.Tags) > 0 { + sb.WriteString("tags: ") + sb.WriteString(strings.Join(n.Meta.Tags, ",")) + sb.WriteString(dotLF) + } + } + + return sb.String(), color +} diff --git a/internal/builder/dot_test.go b/internal/builder/dot_test.go index e9458e4..572cfe5 100644 --- a/internal/builder/dot_test.go +++ b/internal/builder/dot_test.go @@ -171,6 +171,18 @@ func TestDOTGolden(t *testing.T) { Port: &node.Port{Kind: "tcp", Value: 2}, }) + bld.AddEdge(&node.Edge{ + SrcID: "q", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-2", + DstID: "1", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + var buf bytes.Buffer bld.Write(&buf) diff --git a/internal/builder/json.go b/internal/builder/json.go index 8fe2196..a9b9dd3 100644 --- a/internal/builder/json.go +++ b/internal/builder/json.go @@ -49,7 +49,7 @@ func (j *JSON) AddEdge(e *node.Edge) { src.Connected[dst.Name] = append(con, &node.Connection{ Src: e.SrcName, Dst: e.DstName, - Port: e.Port.Label(), + Port: e.Port, }) } diff --git a/internal/builder/json_test.go b/internal/builder/json_test.go index e0d4f20..34f6765 100644 --- a/internal/builder/json_test.go +++ b/internal/builder/json_test.go @@ -28,8 +28,11 @@ func TestJSON(t *testing.T) { testNode := node.JSON{ Name: "test1", Networks: []string{"test"}, - Listen: map[string][]string{ - "foo": {"2/tcp", "1/udp"}, + Listen: map[string][]*node.Port{ + "foo": { + &node.Port{Kind: "tcp", Value: 2}, + &node.Port{Kind: "udp", Value: 1}, + }, }, Tags: []string{}, Connected: make(map[string][]*node.Connection), @@ -96,15 +99,19 @@ func TestJSONAddEdge(t *testing.T) { "name": "test1", "is_external": false, "networks": ["test"], - "listen": {"foo":["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo":[ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "is_external": false, "networks": ["test"], - "listen": {"bar": ["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }` bldr := builder.NewJSON() diff --git a/internal/builder/puml.go b/internal/builder/puml.go new file mode 100644 index 0000000..927a13f --- /dev/null +++ b/internal/builder/puml.go @@ -0,0 +1,237 @@ +package builder + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "hash/fnv" + "io" + "slices" + + "github.com/s0rg/decompose/internal/node" +) + +type PlantUML struct { + nodes map[string]*node.Node + conns map[string]map[string][]*node.Port + order []string +} + +func NewPlantUML() *PlantUML { + return &PlantUML{ + nodes: make(map[string]*node.Node), + conns: make(map[string]map[string][]*node.Port), + } +} + +func (p *PlantUML) Name() string { + return "plant-uml" +} + +func (p *PlantUML) AddNode(n *node.Node) error { + p.nodes[n.ID] = n + p.order = append(p.order, n.ID) + + return nil +} + +func (p *PlantUML) AddEdge(e *node.Edge) { + nsrc, ok := p.nodes[e.SrcID] + if !ok { + return + } + + ndst, ok := p.nodes[e.DstID] + if !ok { + return + } + + if !e.Port.Local && nsrc.Cluster != ndst.Cluster { + e.SrcID, e.DstID = nsrc.Cluster, ndst.Cluster + } + + mdst, ok := p.conns[e.SrcID] + if !ok { + mdst = make(map[string][]*node.Port) + p.conns[e.SrcID] = mdst + } + + ports, ok := mdst[e.DstID] + if !ok { + ports = make([]*node.Port, 0, 1) + } + + mdst[e.DstID] = append(ports, e.Port) +} + +func (p *PlantUML) Write(w io.Writer) error { + fmt.Fprintln(w, "@startuml") + fmt.Fprintln(w, "skinparam componentStyle rectangle") + fmt.Fprintln(w, "skinparam nodesep 5") + fmt.Fprintln(w, "skinparam ranksep 5") + + p.writeNodes(w) + + fmt.Fprintln(w, "") + + p.writeEdges(w) + + fmt.Fprintln(w, "@enduml") + + return nil +} + +func (p *PlantUML) writeNodes(w io.Writer) { + slices.Sort(p.order) + + cloud := []*node.Node{} + + for _, id := range p.order { + nod := p.nodes[id] + + if nod.IsExternal() { + cloud = append(cloud, nod) + + continue + } + + np := []*node.Port{} + + fmt.Fprintf(w, "component \"%s\" as %s {\n", nod.Name, + makeID(nod.Cluster, nod.Name), + ) + + nod.Ports.Iter(func(process string, ports []*node.Port) { + fmt.Fprintf(w, " component \"%s\" as %s {\n", + process, + makeID(nod.Cluster, nod.Name, process), + ) + + for _, prt := range ports { + fmt.Fprintf(w, " portin \"%s\" as %s\n", + prt.Label(), + makeID(nod.Cluster, nod.Name, process, prt.Label()), + ) + + if !prt.Local { + np = append(np, prt) + } + } + + fmt.Fprintln(w, " }") + }) + + for _, prt := range np { + fmt.Fprintf(w, " portin \"%s\" as %s\n", + prt.Label(), + makeID(nod.Cluster, nod.Name, prt.Label()), + ) + } + + fmt.Fprintln(w, "}") + } + + if len(cloud) > 0 { + fmt.Fprintln(w, "cloud \"Externals\" as ext {") + + for _, nod := range cloud { + fmt.Fprintf(w, " component \"%s\" as %s {\n", nod.Name, makeID("ext", nod.Name)) + + nod.Ports.Iter(func(_ string, ports []*node.Port) { + for _, prt := range ports { + fmt.Fprintf(w, " portin \"%s\" as %s\n", + prt.Label(), + makeID("ext", nod.Name, prt.Label()), + ) + } + + fmt.Fprintln(w, " }") + }) + + fmt.Fprintln(w, " }") + } + } + + fmt.Fprintln(w, "}") +} + +func (p *PlantUML) writeEdges(w io.Writer) { + order := make([]string, 0, len(p.conns)) + + for k := range p.conns { + order = append(order, k) + } + + slices.Sort(order) + + for _, src := range order { + dmap := p.conns[src] + + nsrc, ok := p.nodes[src] + if !ok { + continue + } + + locals := make(map[string]string) + + nsrc.Ports.Iter(func(process string, ports []*node.Port) { + for _, prt := range ports { + locals[prt.Label()] = process + } + }) + + nsrc.Ports.Iter(func(process string, ports []*node.Port) { + for _, prt := range ports { + if !prt.Local { + fmt.Fprintf(w, "%s -> %s\n", + makeID(nsrc.Cluster, nsrc.Name, prt.Label()), + makeID(nsrc.Cluster, nsrc.Name, process, prt.Label()), + ) + } + } + }) + + dorder := make([]string, 0, len(dmap)) + + for k := range dmap { + dorder = append(dorder, k) + } + + slices.Sort(dorder) + + for _, dst := range dorder { + ports := dmap[dst] + ndst := p.nodes[dst] + + for _, prt := range ports { + if prt.Local { + dstp := locals[prt.Label()] + + fmt.Fprintf(w, "%s --> %s\n", + makeID(nsrc.Cluster, nsrc.Name), + makeID(nsrc.Cluster, nsrc.Name, dstp, prt.Label()), + ) + } else { + fmt.Fprintf(w, "%s -----> %s: %s\n", + makeID(nsrc.Cluster, nsrc.Name), + makeID(ndst.Cluster, ndst.Name, prt.Label()), + prt.Label(), + ) + } + } + } + } +} + +func makeID(parts ...string) (rv string) { + h := fnv.New64a() + + for _, p := range parts { + _, _ = io.WriteString(h, p) + } + + b := make([]byte, binary.MaxVarintLen64) + n := binary.PutUvarint(b, h.Sum64()) + + return "id_" + hex.EncodeToString(b[:n]) +} diff --git a/internal/builder/puml_test.go b/internal/builder/puml_test.go new file mode 100644 index 0000000..e83e967 --- /dev/null +++ b/internal/builder/puml_test.go @@ -0,0 +1,230 @@ +package builder_test + +import ( + "bytes" + "testing" + + "github.com/s0rg/decompose/internal/builder" + "github.com/s0rg/decompose/internal/node" +) + +func TestPumlGolden(t *testing.T) { + t.Parallel() + + bld := builder.NewPlantUML() + + _ = bld.AddNode(&node.Node{ + ID: "node-1", + Name: "1", + Image: "node-image", + Cluster: "c1", + Ports: makeTestPorts([]*node.Port{ + {Kind: "tcp", Value: 1}, + {Kind: "tcp", Value: 2}, + {Kind: "tcp", Value: 5, Local: true}, + }...), + Networks: []string{"test-net"}, + Meta: &node.Meta{ + Info: "info 1", + Docs: "docs-url", + Repo: "repo-url", + Tags: []string{"1"}, + }, + }) + _ = bld.AddNode(&node.Node{ + ID: "node-2", + Name: "2", + Image: "node-image", + Cluster: "c1", + Ports: makeTestPorts([]*node.Port{ + {Kind: "tcp", Value: 1}, + {Kind: "tcp", Value: 2}, + }...), + Networks: []string{"test-net"}, + Meta: &node.Meta{ + Info: "info 2", + Tags: []string{"2"}, + }, + }) + _ = bld.AddNode(&node.Node{ + ID: "node-3", + Name: "3", + Image: "node-image", + Ports: makeTestPorts([]*node.Port{ + {Kind: "tcp", Value: 1}, + {Kind: "tcp", Value: 2}, + }...), + Networks: []string{"test-net"}, + Meta: &node.Meta{ + Info: "info 3", + Tags: []string{"3"}, + }, + }) + + _ = bld.AddNode(&node.Node{ + ID: "ext2", + Name: "ext2", + Cluster: "c2", + Ports: makeTestPorts([]*node.Port{ + {Kind: "tcp", Value: 2}, + }...), + }) + + _ = bld.AddNode(&node.Node{ + ID: "ext2", + Name: "ext2", + Cluster: "c2", + Ports: makeTestPorts([]*node.Port{ + {Kind: "tcp", Value: 443}, + }...), + }) + + bld.AddEdge(&node.Edge{ + SrcID: "ext2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 1}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 5, Local: true}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "ext2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "ext2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 3}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "ext2", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "ext2", + Port: &node.Port{Kind: "tcp", Value: 3}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "3", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "3", + DstID: "node-1", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 1}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-2", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 3}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-2", + DstID: "node-3", + Port: &node.Port{Kind: "tcp", Value: 3}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "node-3", + Port: &node.Port{Kind: "tcp", Value: 3}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "node-2", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "c2", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-3", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 1}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-3", + DstID: "node-1", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "c1", + DstID: "2", + Port: &node.Port{Kind: "tcp", Value: 2}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "c1", + DstID: "c2", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "c1", + DstID: "", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "", + DstID: "c2", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-1", + DstID: "node-4", + Port: &node.Port{}, + }) + + bld.AddEdge(&node.Edge{ + SrcID: "node-3", + DstID: "node-1", + Port: &node.Port{}, + }) + + var buf bytes.Buffer + + bld.Write(&buf) + + got := buf.String() + want := golden(t, bld.Name(), got) + + if got != want { + t.Errorf("Want:\n%s\nGot:\n%s", want, got) + } +} diff --git a/internal/builder/sdsl.go b/internal/builder/sdsl.go index c6fe7ef..1547802 100644 --- a/internal/builder/sdsl.go +++ b/internal/builder/sdsl.go @@ -48,10 +48,23 @@ func (s *Structurizr) AddNode(n *node.Node) error { cont.Technology = n.Image - n.Ports.Iter(func(_ string, plist []*node.Port) { + n.Ports.Iter(func(process string, plist []*node.Port) { + com := &sdsl.Component{ + ID: sdsl.SafeID(n.ID + "_" + process), + Name: process, + } + for _, p := range plist { - cont.Tags = append(cont.Tags, "listen:"+p.Label()) + tag := "listen:" + p.Label() + + com.Tags = append(com.Tags, tag) + + if !p.Local { + cont.Tags = append(cont.Tags, tag) + } } + + cont.Components = append(cont.Components, com) }) for _, n := range n.Networks { @@ -62,10 +75,14 @@ func (s *Structurizr) AddNode(n *node.Node) error { cont.Tags = append(cont.Tags, "external") } - if lines, ok := n.FormatMeta(); ok { - cont.Description = strings.Join(lines, " \\\n") + if n.Meta != nil { + if lines, ok := n.FormatMeta(); ok { + cont.Description = strings.Join(lines, " \\\n") + } - cont.Tags = append(cont.Tags, n.Meta.Tags...) + if len(n.Meta.Tags) > 0 { + cont.Tags = append(cont.Tags, n.Meta.Tags...) + } } return nil diff --git a/internal/builder/stat_test.go b/internal/builder/stat_test.go index 97f4571..ba44588 100644 --- a/internal/builder/stat_test.go +++ b/internal/builder/stat_test.go @@ -34,14 +34,17 @@ func TestStat(t *testing.T) { "name": "test1", "is_external": false, "networks": ["test"], - "listen": {"1": ["1/udp", "1/tcp"]}, + "listen": {"1": [ + {"kind": "tcp", "value": 1}, + {"kind": "udp", "value": 1} + ]}, "connected": { "test2":[ - {"src": "1", "dst": "2", "port": "2/tcp"}, - {"src": "1", "dst": "2", "port": "2/udp"} + {"src": "1", "dst": "2", "port": {"kind": "tcp", "value": 2}}, + {"src": "1", "dst": "2", "port": {"kind": "udp", "value": 2}} ], "test3":[ - {"src": "1", "dst": "3", "port": "3/tcp"} + {"src": "1", "dst": "3", "port": {"kind": "tcp", "value": 3}} ] } } @@ -49,14 +52,17 @@ func TestStat(t *testing.T) { "name": "test2", "is_external": false, "networks": ["test"], - "listen": {"2":["2/tcp", "2/udp"]}, + "listen": {"2":[ + {"kind": "tcp", "value": 2}, + {"kind": "udp", "value": 2} + ]}, "connected": { "test1":[ - {"src": "2", "dst": "1", "port": "1/tcp"}, - {"src": "2", "dst": "1", "port": "1/udp"} + {"src": "2", "dst": "1", "port": {"kind": "tcp", "value": 1}}, + {"src": "2", "dst": "1", "port": {"kind": "udp", "value": 1}} ], "test3": [ - {"src": "2", "dst": "3", "port": "3/udp"} + {"src": "2", "dst": "3", "port": {"kind": "udp", "value": 3}} ] } } @@ -64,7 +70,10 @@ func TestStat(t *testing.T) { "name": "test3", "is_external": true, "networks": ["test"], - "listen": {"3":["3/tcp", "3/udp"]}, + "listen": {"3":[ + {"kind": "tcp", "value": 3}, + {"kind": "udp", "value": 3} + ]}, "connected": {} }` @@ -142,14 +151,17 @@ func TestStatCluster(t *testing.T) { "name": "test1", "is_external": false, "networks": ["test"], - "listen": {"1": ["1/udp", "1/tcp"]}, + "listen": {"1": [ + {"kind": "tcp", "value": 1}, + {"kind": "udp", "value": 1} + ]}, "connected": { "test2":[ - {"src": "1", "dst": "2", "port": "2/tcp"}, - {"src": "1", "dst": "2", "port": "2/udp"} + {"src": "1", "dst": "2", "port": {"kind": "tcp", "value": 2}}, + {"src": "1", "dst": "2", "port": {"kind": "udp", "value": 2}} ], "test3":[ - {"src": "1", "dst": "3", "port": "3/tcp"} + {"src": "1", "dst": "3", "port": {"kind": "tcp", "value": 3}} ] } } @@ -157,14 +169,17 @@ func TestStatCluster(t *testing.T) { "name": "test2", "is_external": false, "networks": ["test"], - "listen": {"2":["2/tcp", "2/udp"]}, + "listen": {"2":[ + {"kind": "tcp", "value": 2}, + {"kind": "udp", "value": 2} + ]}, "connected": { "test1":[ - {"src": "2", "dst": "1", "port": "1/tcp"}, - {"src": "2", "dst": "1", "port": "1/udp"} + {"src": "2", "dst": "1", "port": {"kind": "tcp", "value": 1}}, + {"src": "2", "dst": "1", "port": {"kind": "udp", "value": 1}} ], "test3": [ - {"src": "2", "dst": "3", "port": "3/udp"} + {"src": "2", "dst": "3", "port": {"kind": "udp", "value": 3}} ] } }` diff --git a/internal/builder/testdata/csv.golden b/internal/builder/testdata/csv.golden index dc302fb..0931f4e 100644 --- a/internal/builder/testdata/csv.golden +++ b/internal/builder/testdata/csv.golden @@ -1,7 +1,7 @@ service,listen,outbounds 1,"1/tcp -2/tcp","3: 3/tcp -2: 2/tcp +2/tcp","2: 2/tcp +3: 3/tcp " 2,2/tcp,"1: 1/tcp; 2/tcp; 3/tcp 3: 3/tcp diff --git a/internal/builder/testdata/graphviz-dot.golden b/internal/builder/testdata/graphviz-dot.golden index e55162e..eb0fdd2 100644 --- a/internal/builder/testdata/graphviz-dot.golden +++ b/internal/builder/testdata/graphviz-dot.golden @@ -1,38 +1,38 @@ digraph { subgraph cluster_s1 { label="c1"; - n2[label="out"]; n12[label="1/tcp"]; - n3[color="black",label=" 1\nimage: node-image\nnet: test-net\ninfo:\ninfo 1\ndocs-url\nrepo-url\ntags: 1|{ 1/tcp| 2/tcp}",shape="record"]; - n4[color="black",label=" 2\nimage: node-image\nnet: test-net\ninfo:\ninfo 2\ntags: 2|{ 1/tcp| 2/tcp}",shape="record"]; + n2[label="out"]; + n3[color="black",label=" 1\nimage: node-image\nnets: test-net\nmeta: \ninfo 1\ndocs-url\nrepo-url\ntags: 1\n|{ }",shape="record"]; + n4[color="black",label=" 2\nimage: node-image\nnets: test-net\nmeta: \ninfo 2\ntags: 2\n|{ }",shape="record"]; } subgraph cluster_s8 { label="c2"; - n10[color="red",label=" external: 2|{ 2/tcp}",shape="record"]; - n9[label="out"]; n11[label="2/tcp"]; + n10[color="gray",label=" external: 2\n|{ }",shape="record"]; + n9[label="out"]; } subgraph cluster_s5 { label="c3"; n6[label="out"]; - n7[color="black",label=" 3\nimage: node-image\nnet: test-net\ninfo:\ninfo 3\ntags: 3|{ 1/tcp| 2/tcp}",shape="record"]; + n7[color="black",label=" 3\nimage: node-image\nnets: test-net\nmeta: \ninfo 3\ntags: 3\n|{ }",shape="record"]; } concentrate=true;splines="spline"; - n10:out->n3:tcp1[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; - n10:out->n3:tcp2[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; - n10:out->n3:tcp3[color="#7570b3",fontcolor="#7570b3",label="3/tcp"]; + n10:out->n3:node-1[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; + n10:out->n3:node-1[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; + n10:out->n3:node-1[color="#7570b3",fontcolor="#7570b3",label="3/tcp"]; n2->n11[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; n2->n11[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; n9->n12[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; n9->n12[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; - n3:out->n10:tcp2[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; - n3:out->n10:tcp3[color="#7570b3",fontcolor="#7570b3",label="3/tcp"]; - n3:out->n4:tcp1[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; - n3:out->n4:tcp1[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; - n4:out->n3:tcp2[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; - n4:out->n3:tcp2[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; + n3:out->n10:2[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; + n3:out->n10:2[color="#7570b3",fontcolor="#7570b3",label="3/tcp"]; + n3:out->n4:node-2[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; + n3:out->n4:node-2[color="#66a61e",fontcolor="#66a61e",label="1/tcp"]; + n4:out->n3:node-1[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; + n4:out->n3:node-1[color="#e7298a",fontcolor="#e7298a",label="2/tcp"]; } diff --git a/internal/builder/testdata/plant-uml.golden b/internal/builder/testdata/plant-uml.golden new file mode 100644 index 0000000..e3ca132 --- /dev/null +++ b/internal/builder/testdata/plant-uml.golden @@ -0,0 +1,50 @@ +@startuml +skinparam componentStyle rectangle +skinparam nodesep 5 +skinparam ranksep 5 +component "1" as id_b8d1bdeb90c390c3f601 { + component "" as id_b8d1bdeb90c390c3f601 { + portin "1/tcp" as id_d39ac7cbd4ded393cd01 + portin "2/tcp" as id_f692a2faacfbd4a6b501 + portin "5/tcp" as id_ffbfeddcc8fcb4e09901 + } + portin "1/tcp" as id_d39ac7cbd4ded393cd01 + portin "2/tcp" as id_f692a2faacfbd4a6b501 +} +component "2" as id_d1dbbdeb90a391c3f601 { + component "" as id_d1dbbdeb90a391c3f601 { + portin "1/tcp" as id_988bfed49acfb0f020 + portin "2/tcp" as id_d9c5d0b2bdec95fb60 + } + portin "1/tcp" as id_988bfed49acfb0f020 + portin "2/tcp" as id_d9c5d0b2bdec95fb60 +} +component "3" as id_e2bc86b0c8c9ebb1af01 { + component "" as id_e2bc86b0c8c9ebb1af01 { + portin "1/tcp" as id_a9c28885c4aedcb323 + portin "2/tcp" as id_a89eb990a1ceb69b8e01 + } + portin "1/tcp" as id_a9c28885c4aedcb323 + portin "2/tcp" as id_a89eb990a1ceb69b8e01 +} +cloud "Externals" as ext { + component "ext2" as id_a7fadbdd96b2cbabd101 { + portin "443/tcp" as id_b494f7ce9df8ea8aad01 + } + } + component "ext2" as id_a7fadbdd96b2cbabd101 { + portin "443/tcp" as id_b494f7ce9df8ea8aad01 + } + } +} + +id_d39ac7cbd4ded393cd01 -> id_d39ac7cbd4ded393cd01 +id_f692a2faacfbd4a6b501 -> id_f692a2faacfbd4a6b501 +id_b8d1bdeb90c390c3f601 --> id_ffbfeddcc8fcb4e09901 +id_b8d1bdeb90c390c3f601 -----> id_d9c5d0b2bdec95fb60: 2/tcp +id_988bfed49acfb0f020 -> id_988bfed49acfb0f020 +id_d9c5d0b2bdec95fb60 -> id_d9c5d0b2bdec95fb60 +id_d1dbbdeb90a391c3f601 -----> id_d39ac7cbd4ded393cd01: 1/tcp +id_d1dbbdeb90a391c3f601 -----> id_f692a2faacfbd4a6b501: 2/tcp +id_d1dbbdeb90a391c3f601 -----> id_a1aca2e8affb90bf9701: 3/tcp +@enduml diff --git a/internal/builder/testdata/structurizr-dsl.golden b/internal/builder/testdata/structurizr-dsl.golden index e33c2b2..3784dab 100644 --- a/internal/builder/testdata/structurizr-dsl.golden +++ b/internal/builder/testdata/structurizr-dsl.golden @@ -4,45 +4,51 @@ workspace { model { default = softwareSystem "default" { tags "3" - 3 = container "3" { + node_3 = container "3" { description "info 3" technology "node-image" tags "3,listen:1/tcp,listen:2/tcp,net:test-net" + node_3_ = component "" { + tags "listen:1/tcp,listen:2/tcp" + } } } c1 = softwareSystem "c1" { tags "1,2" - 1 = container "1" { + node_1 = container "1" { description "info 1 \ docs-url \ repo-url" technology "node-image" tags "1,listen:1/tcp,listen:2/tcp,net:test-net" + node_1_ = component "" { + tags "listen:1/tcp,listen:2/tcp" + } } - 2 = container "2" { + node_2 = container "2" { description "info 2" technology "node-image" tags "2,listen:1/tcp,listen:2/tcp,net:test-net" + node_2_ = component "" { + tags "listen:1/tcp,listen:2/tcp" + } } } c2 = softwareSystem "c2" { tags "ext2" ext2 = container "ext2" { tags "external,listen:2/tcp" + ext2_ = component "" { + tags "listen:2/tcp" + } } } - c1 -> c2 { - description "c1 to c2 " - tags "0/" + c1 -> c2 "0/" { } - c1 -> default { - description "c1 to default " - tags "0/" + c1 -> default "0/" { } - default -> c2 { - description "default to c2 " - tags "0/" + default -> c2 "0/" { } } @@ -57,6 +63,10 @@ repo-url" include * autoLayout } + component node_3 "component_node_3" { + include * + autoLayout + } systemContext c1 "systemContext_c1" { include * autoLayout @@ -65,6 +75,14 @@ repo-url" include * autoLayout } + component node_1 "component_node_1" { + include * + autoLayout + } + component node_2 "component_node_2" { + include * + autoLayout + } systemContext c2 "systemContext_c2" { include * autoLayout @@ -73,6 +91,10 @@ repo-url" include * autoLayout } + component ext2 "component_ext2" { + include * + autoLayout + } styles { element "Element" { diff --git a/internal/builder/tree.go b/internal/builder/tree.go index 32b73bb..66b9c7c 100644 --- a/internal/builder/tree.go +++ b/internal/builder/tree.go @@ -129,27 +129,3 @@ func writeNode(w io.Writer, n *node.JSON, last bool) { fmt.Fprintln(w, next) } } - -func joinConnections(conns []*node.Connection, sep string) (rv string) { - raw := make([]string, 0, len(conns)) - - for _, c := range conns { - raw = append(raw, c.Port) - } - - slices.Sort(raw) - - return strings.Join(raw, sep) -} - -func joinListeners(ports map[string][]string, sep string) (rv string) { - var tmp []string - - for _, plist := range ports { - tmp = append(tmp, plist...) - } - - slices.Sort(tmp) - - return strings.Join(tmp, sep) -} diff --git a/internal/client/docker.go b/internal/client/docker.go index 2bc4074..427327a 100644 --- a/internal/client/docker.go +++ b/internal/client/docker.go @@ -116,7 +116,7 @@ func (d *Docker) Containers( } if err := d.connections(ctx, doc.ID, proto, func(conn *graph.Connection) { - if !deep && conn.LocalIP.IsLoopback() { + if !deep && conn.IsLocal() { return } diff --git a/internal/client/docker_test.go b/internal/client/docker_test.go index 711a210..46bd8ff 100644 --- a/internal/client/docker_test.go +++ b/internal/client/docker_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "errors" + "net" "testing" "github.com/docker/docker/api/types" @@ -814,6 +815,81 @@ func TestDockerClientNsEnterConnectionsError(t *testing.T) { } } +func TestDockerClientNsEnterContainerTopVariants(t *testing.T) { + t.Parallel() + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cm.OnContainerTop = func() (rv container.ContainerTopOKBody) { + rv.Titles = []string{"PID"} + rv.Processes = [][]string{ + {}, + {"a"}, + {"1"}, + } + + return rv + } + + var count int + + enter := func(_ int, _ graph.NetProto, _ func( + _ *graph.Connection, + )) error { + count++ + + return nil + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.LinuxNsenter), + client.WithNsEnter(enter), + ) + if err != nil { + t.Fatal("client:", err) + } + + if _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + false, + nil, + voidProgress, + ); err != nil { + t.Fatal() + } + + if count != 1 { + t.Fail() + } +} + func TestDockerClientNsEnterOk(t *testing.T) { t.Parallel() @@ -881,3 +957,100 @@ func TestDockerClientNsEnterOk(t *testing.T) { t.Fatal("containers:", err) } } + +func TestDockerClientNsEnterLocal(t *testing.T) { + t.Parallel() + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + }, + }, + }, + } + } + + cm.OnContainerTop = func() (rv container.ContainerTopOKBody) { + rv.Titles = []string{"PID"} + rv.Processes = [][]string{ + {"1"}, + } + + return rv + } + + testEnter := func(_ int, _ graph.NetProto, fn func(*graph.Connection)) error { + loc := net.ParseIP("127.0.0.1") + nod := net.ParseIP("1.1.1.1") + rem := net.ParseIP("2.2.2.2") + + fn(&graph.Connection{Process: "1", LocalPort: 1, RemotePort: 0, LocalIP: nod, Proto: graph.TCP}) + fn(&graph.Connection{Process: "1", LocalPort: 10, RemotePort: 2, LocalIP: nod, RemoteIP: rem, Proto: graph.TCP}) + fn(&graph.Connection{Process: "1", LocalPort: 5, LocalIP: loc, Proto: graph.TCP}) + + return nil + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.LinuxNsenter), + client.WithNsEnter(testEnter), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + false, + false, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 1 { + t.Fail() + } + + if rv[0].ConnectionsCount() != 2 { + t.Fail() + } + + rv, err = cli.Containers( + context.Background(), + graph.ALL, + false, + true, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 1 { + t.Fail() + } + + if rv[0].ConnectionsCount() != 3 { + t.Fail() + } +} diff --git a/internal/cluster/node.go b/internal/cluster/node.go index 796e9de..5376d41 100644 --- a/internal/cluster/node.go +++ b/internal/cluster/node.go @@ -37,7 +37,7 @@ func (n *Node) Match(id string, o *Node) (rv float64) { func (n *Node) Merge(o *Node) { n.Ports.Join(o.Ports) - n.Ports.Sort() + n.Ports.Compact() n.Inbounds = set.Union(n.Inbounds, o.Inbounds) n.Outbounds = set.Union(n.Outbounds, o.Outbounds) diff --git a/internal/cluster/node_test.go b/internal/cluster/node_test.go index 3577949..6e91765 100644 --- a/internal/cluster/node_test.go +++ b/internal/cluster/node_test.go @@ -39,3 +39,31 @@ func TestNodeMatch(t *testing.T) { t.FailNow() } } + +func TestNodeMatchPorts(t *testing.T) { + t.Parallel() + + a := &cluster.Node{ + Inbounds: make(set.Unordered[string]), + Outbounds: make(set.Unordered[string]), + Ports: &node.Ports{}, + } + + a.Ports.Add("", &node.Port{Kind: "tcp", Value: 1}) + + b := &cluster.Node{ + Inbounds: make(set.Unordered[string]), + Outbounds: make(set.Unordered[string]), + Ports: &node.Ports{}, + } + + b.Ports.Add("", &node.Port{Kind: "tcp", Value: 1}) + b.Ports.Add("", &node.Port{Kind: "tcp", Value: 5}) + + a.Inbounds.Add("1") + a.Outbounds.Add("2") + + if a.Match("", b) != 0.5 { + t.Fail() + } +} diff --git a/internal/graph/build.go b/internal/graph/build.go index d9d06c0..b5004df 100644 --- a/internal/graph/build.go +++ b/internal/graph/build.go @@ -210,10 +210,6 @@ func buildEdges( return } - if !cfg.MatchName(ldst.Name) && !cfg.MatchName(con.Name) { - return - } - dst, found := nodes[ldst.ID] if !found { return diff --git a/internal/graph/build_test.go b/internal/graph/build_test.go index 327671a..8b78701 100644 --- a/internal/graph/build_test.go +++ b/internal/graph/build_test.go @@ -152,6 +152,7 @@ func testClientWithEnv() graph.ContainerClient { cli.Data[2].AddMany([]*graph.Connection{ {LocalPort: 3, Proto: graph.TCP}, // listen 3 {RemoteIP: node1, LocalPort: 10, RemotePort: 1, Proto: graph.TCP}, // connected to node1:1 + {RemoteIP: node2, LocalPort: 122, RemotePort: 22, Proto: graph.TCP}, // connected to node2:22 {RemoteIP: external, LocalPort: 10, RemotePort: 3, Proto: graph.TCP}, // connected to external:3 }) @@ -174,7 +175,7 @@ func TestBuildSimple(t *testing.T) { t.Fatalf("err = %v", err) } - if bld.Nodes != 4 || bld.Edges != 6 { + if bld.Nodes != 4 || bld.Edges != 7 { t.Fail() } } @@ -221,7 +222,7 @@ func TestBuildLocal(t *testing.T) { t.Fatalf("err = %v", err) } - if bld.Nodes != 3 || bld.Edges != 3 { + if bld.Nodes != 3 || bld.Edges != 4 { t.Fail() } } @@ -230,7 +231,7 @@ func TestBuildNoNodes(t *testing.T) { t.Parallel() flw := make(set.Unordered[string]) - flw.Add("4") + flw.Add("5") cli := testClientWithEnv() cfg := &graph.Config{ @@ -293,6 +294,7 @@ func TestBuildLoops(t *testing.T) { node1 := net.ParseIP("1.1.1.1") node2 := net.ParseIP("1.1.1.2") + local := net.ParseIP("127.0.0.1") cli := &testClient{Data: []*graph.Container{ makeContainer("1", node1.String()), @@ -308,6 +310,7 @@ func TestBuildLoops(t *testing.T) { cli.Data[1].AddMany([]*graph.Connection{ {LocalPort: 2, Proto: graph.TCP}, // listen 2 {RemoteIP: node1, LocalPort: 10, RemotePort: 1, Proto: graph.TCP}, // connected to node1:1 + {RemoteIP: local, LocalPort: 11, RemotePort: 2, Proto: graph.TCP}, // connected to self:2 }) bld := &testBuilder{} @@ -323,7 +326,7 @@ func TestBuildLoops(t *testing.T) { t.Fatalf("err = %v", err) } - if bld.Nodes != 2 || bld.Edges != 3 { + if bld.Nodes != 2 || bld.Edges != 4 { t.Fail() } diff --git a/internal/graph/compress.go b/internal/graph/compress.go new file mode 100644 index 0000000..ea768f2 --- /dev/null +++ b/internal/graph/compress.go @@ -0,0 +1,253 @@ +package graph + +import ( + "cmp" + "fmt" + "io" + "log" + "slices" + "strings" + + "github.com/s0rg/decompose/internal/node" + "github.com/s0rg/set" + "github.com/s0rg/trie" +) + +const ( + externalGroup = "external" + defaultGroup = "core" + defaultDiff = 3 +) + +type Compressor struct { + b NamedBuilderWriter + nodes map[string]*node.Node + conns map[string]map[string][]*node.Port + edges int +} + +func NewCompressor( + bldr NamedBuilderWriter, +) *Compressor { + return &Compressor{ + b: bldr, + nodes: make(map[string]*node.Node), + conns: make(map[string]map[string][]*node.Port), + } +} + +func (c *Compressor) AddNode(n *node.Node) error { + c.nodes[n.ID] = n + + return nil +} + +func (c *Compressor) AddEdge(e *node.Edge) { + nsrc, ok := c.nodes[e.SrcID] + if !ok { + return + } + + ndst, ok := c.nodes[e.DstID] + if !ok { + return + } + + dmap, ok := c.conns[nsrc.ID] + if !ok { + dmap = make(map[string][]*node.Port) + c.conns[nsrc.ID] = dmap + } + + dmap[ndst.ID] = append(dmap[ndst.ID], e.Port) + c.edges++ +} + +func (c *Compressor) Name() string { + return c.b.Name() + " [compressed]" +} + +func (c *Compressor) Write(w io.Writer) (err error) { + index, err := c.buildGroups() + if err != nil { + return err + } + + c.buildEdges(index) + + if err = c.b.Write(w); err != nil { + return fmt.Errorf("compressor write [%s]: %w", c.b.Name(), err) + } + + return nil +} + +func (c *Compressor) buildGroups() (index map[string]string, err error) { + seen := make(set.Unordered[string]) + t := trie.New[string]() + + for _, node := range c.nodes { + seen.Add(node.ID) + + if node.IsExternal() { + continue + } + + t.Add(node.Name, node.ID) + } + + comm := t.Common("", defaultDiff) + groups := make(map[string][]string) + index = make(map[string]string) + + for _, key := range comm { + nodes := []string{} + + t.Iter(key, func(_, nodeID string) { + nodes = append(nodes, nodeID) + }) + + grp := defaultGroup + if len(nodes) > 1 { + grp = cleanName(key) + } + + for _, nodeID := range nodes { + index[nodeID] = grp + + seen.Del(nodeID) + } + + groups[grp] = nodes + } + + seen.Iter(func(id string) (next bool) { + grp := defaultGroup + + if c.nodes[id].IsExternal() { + grp = externalGroup + } + + groups[grp] = append(groups[grp], id) + index[id] = grp + + return true + }) + + for grp, nodes := range groups { + batch := make([]*node.Node, len(nodes)) + + for i, nodeID := range nodes { + batch[i] = c.nodes[nodeID] + } + + if err = c.b.AddNode(compressNodes(grp, batch)); err != nil { + err = fmt.Errorf("compressor add-node [%s]: %w", c.b.Name(), err) + + return nil, err + } + } + + log.Printf("[compress] nodes %d -> %d %.02f%%", + len(c.nodes), + len(groups), + percentOf(len(c.nodes)-len(groups), len(c.nodes)), + ) + + return index, nil +} + +func (c *Compressor) buildEdges(index map[string]string) { + gconns := make(map[string]map[string][]*node.Port) + + for src, dmap := range c.conns { + srcg := index[src] + + gmap, ok := gconns[srcg] + if !ok { + gmap = make(map[string][]*node.Port) + gconns[srcg] = gmap + } + + for dst, ports := range dmap { + dstg := index[dst] + + gmap[dstg] = append(gmap[dstg], ports...) + } + } + + var count int + + for src, dmap := range gconns { + for dst, ports := range dmap { + ports = compressPorts(ports) + + for _, port := range ports { + c.b.AddEdge(&node.Edge{ + SrcID: src, + DstID: dst, + Port: port, + }) + + count++ + } + } + } + + log.Printf("[compress] edges %d -> %d %.02f%%", + c.edges, + count, + percentOf(c.edges-count, c.edges), + ) +} + +func cleanName(a string) string { + const cutset = "0123456789-" + + return strings.TrimRight(a, cutset) +} + +func compressNodes(id string, nodes []*node.Node) (rv *node.Node) { + ports := &node.Ports{} + tags := make([]string, len(nodes)) + + for i, n := range nodes { + tags[i] = n.Name + + n.Ports.Iter(func(_ string, plist []*node.Port) { + for _, p := range plist { + ports.Add(n.Name, p) + } + }) + } + + ports.Compact() + + name := id + if name != externalGroup { + name = strings.ToUpper(name) + } + + return &node.Node{ + ID: id, + Name: name, + Ports: ports, + Meta: &node.Meta{ + Tags: tags, + }, + } +} + +func compressPorts(ports []*node.Port) (rv []*node.Port) { + slices.SortFunc(ports, func(a, b *node.Port) int { + if a.Kind == b.Kind { + return cmp.Compare(a.Value, b.Value) + } + + return cmp.Compare(a.Kind, b.Kind) + }) + + return slices.CompactFunc(ports, func(a, b *node.Port) bool { + return a.Equal(b) + }) +} diff --git a/internal/graph/compress_test.go b/internal/graph/compress_test.go new file mode 100644 index 0000000..aaea2c6 --- /dev/null +++ b/internal/graph/compress_test.go @@ -0,0 +1,244 @@ +package graph_test + +import ( + "errors" + "io" + "log" + "strings" + "testing" + + "github.com/s0rg/decompose/internal/graph" + "github.com/s0rg/decompose/internal/node" +) + +type testNamedBuilder struct { + AddError error + WriteError error + Nodes int + Edges int +} + +func (b *testNamedBuilder) AddNode(n *node.Node) error { + log.Printf("%+v", n) + + b.Nodes++ + + return b.AddError +} + +func (b *testNamedBuilder) AddEdge(e *node.Edge) { + log.Printf("%+v", e) + + b.Edges++ +} + +func (b *testNamedBuilder) Name() string { + return "test-builder" +} + +func (b *testNamedBuilder) Write(_ io.Writer) error { + return b.WriteError +} + +func TestCompressor(t *testing.T) { + t.Parallel() + + tb := &testNamedBuilder{} + + c := graph.NewCompressor(tb) + + // ingress + nginx := &node.Node{ + ID: "1", + Name: "nginx1", + Ports: &node.Ports{}, + } + + nginx.Ports.Add("nginx", &node.Port{Kind: "tcp", Value: 443}) + c.AddNode(nginx) + + // apps + app1 := &node.Node{ + ID: "2", + Name: "app1", + Ports: &node.Ports{}, + } + + app1.Ports.Add("app", &node.Port{Kind: "tcp", Value: 8080}) + c.AddNode(app1) + + app2 := &node.Node{ + ID: "3", + Name: "app2", + Ports: &node.Ports{}, + } + + app2.Ports.Add("app", &node.Port{Kind: "tcp", Value: 8080}) + c.AddNode(app2) + + // dbs + bouncer := &node.Node{ + ID: "4", + Name: "pgbouncer1", + Ports: &node.Ports{}, + } + + bouncer.Ports.Add("pgbouncer", &node.Port{Kind: "tcp", Value: 5432}) + c.AddNode(bouncer) + + db1 := &node.Node{ + ID: "5", + Name: "postgres1", + Ports: &node.Ports{}, + } + + db1.Ports.Add("postgres", &node.Port{Kind: "tcp", Value: 5432}) + c.AddNode(db1) + + db2 := &node.Node{ + ID: "6", + Name: "postgres2", + Ports: &node.Ports{}, + } + + db2.Ports.Add("postgres", &node.Port{Kind: "tcp", Value: 5432}) + c.AddNode(db2) + + // external + ext := &node.Node{ + ID: "EXT", + Name: "EXT", + Ports: &node.Ports{}, + } + + ext.Ports.Add("", &node.Port{Kind: "tcp", Value: 9000}) + c.AddNode(ext) + + // edges + + c.AddEdge(&node.Edge{ + SrcID: "1", + DstID: "2", + Port: &node.Port{Kind: "tcp", Value: 8080}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "1", + DstID: "3", + Port: &node.Port{Kind: "tcp", Value: 8080}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "2", + DstID: "4", + Port: &node.Port{Kind: "tcp", Value: 5432}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "3", + DstID: "4", + Port: &node.Port{Kind: "tcp", Value: 5432}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "4", + DstID: "5", + Port: &node.Port{Kind: "tcp", Value: 5432}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "4", + DstID: "5", + Port: &node.Port{Kind: "tcp", Value: 5432}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "1", + DstID: "2", + Port: &node.Port{Kind: "udp", Value: 22}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "X", + DstID: "2", + Port: &node.Port{Kind: "udp", Value: 22}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "1", + DstID: "X", + Port: &node.Port{Kind: "udp", Value: 22}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "2", + DstID: "EXT", + Port: &node.Port{Kind: "tcp", Value: 9000}, + }) + + c.AddEdge(&node.Edge{ + SrcID: "3", + DstID: "EXT", + Port: &node.Port{Kind: "tcp", Value: 9000}, + }) + + if err := c.Write(nil); err != nil { + t.Fail() + } + + if tb.Edges != 5 || tb.Nodes != 4 { + t.Fail() + } +} + +func TestCompressorName(t *testing.T) { + t.Parallel() + + tb := &testNamedBuilder{} + + c := graph.NewCompressor(tb) + + if !strings.Contains(c.Name(), "compressed") { + t.Fail() + } +} + +func TestCompressorAddError(t *testing.T) { + t.Parallel() + + myErr := errors.New("add error") + tb := &testNamedBuilder{ + AddError: myErr, + } + + c := graph.NewCompressor(tb) + + if err := c.AddNode(&node.Node{Ports: &node.Ports{}}); err != nil { + t.Fail() + } + + err := c.Write(nil) + if !errors.Is(err, myErr) { + t.Fail() + } +} + +func TestCompressorWriteError(t *testing.T) { + t.Parallel() + + myErr := errors.New("write error") + tb := &testNamedBuilder{ + WriteError: myErr, + } + + c := graph.NewCompressor(tb) + + if err := c.AddNode(&node.Node{Ports: &node.Ports{}}); err != nil { + t.Fail() + } + + err := c.Write(nil) + if !errors.Is(err, myErr) { + t.Fail() + } +} diff --git a/internal/graph/connection.go b/internal/graph/connection.go index 731e38f..597f1e9 100644 --- a/internal/graph/connection.go +++ b/internal/graph/connection.go @@ -24,6 +24,10 @@ func (c *Connection) IsInbound() bool { return c.LocalPort < c.RemotePort } +func (c *Connection) IsLocal() bool { + return c.LocalIP.IsLoopback() +} + func (c *Connection) UniqID() (id uint64, ok bool) { var key string diff --git a/internal/graph/container.go b/internal/graph/container.go index dbcfd30..216b6a3 100644 --- a/internal/graph/container.go +++ b/internal/graph/container.go @@ -108,6 +108,7 @@ func (c *Container) ToNode() (rv *node.Node) { c.IterListeners(func(conn *Connection) { rv.Ports.Add(conn.Process, &node.Port{ + Local: conn.IsLocal(), Kind: conn.Proto.String(), Value: int(conn.LocalPort), }) diff --git a/internal/graph/load.go b/internal/graph/load.go index 1329e3a..e613faa 100644 --- a/internal/graph/load.go +++ b/internal/graph/load.go @@ -147,7 +147,7 @@ func (l *Loader) insert(n *node.JSON) { l.cfg.Meta.Enrich(nod) - nod.Ports.Sort() + nod.Ports.Compact() l.nodes[id] = nod l.edges[id] = cons @@ -176,12 +176,7 @@ func (l *Loader) connect( } for _, c := range cl { - port, ok := node.ParsePort(c.Port) - if !ok { - continue - } - - if !l.cfg.MatchProto(port.Kind) { + if !l.cfg.MatchProto(c.Port.Kind) { continue } @@ -190,7 +185,7 @@ func (l *Loader) connect( DstID: dstID, SrcName: c.Src, DstName: c.Dst, - Port: port, + Port: c.Port, }) } } @@ -198,13 +193,11 @@ func (l *Loader) connect( func loadListeners( ports *node.Ports, - conns map[string][]string, + conns map[string][]*node.Port, ) { for k, cl := range conns { for _, p := range cl { - if res, ok := node.ParsePort(p); ok { - ports.Add(k, res) - } + ports.Add(k, p) } } } diff --git a/internal/graph/load_test.go b/internal/graph/load_test.go index 46850bd..1169fda 100644 --- a/internal/graph/load_test.go +++ b/internal/graph/load_test.go @@ -46,7 +46,10 @@ func TestLoaderBuildError(t *testing.T) { "name": "test", "is_remote": false, "image": "test-image", - "listen": {"foo":["1/tcp", "2/udp"]}, + "listen": {"foo":[ + {"kind": "tcp", "value": 1}, + {"kind": "udp", "value": 2} + ]}, "connected": null }`) @@ -82,7 +85,10 @@ func TestLoaderSingle(t *testing.T) { "name": "test", "is_remote": false, "image": "test-image", - "listen": {"foo": ["1/tcp", "2/udp"]}, + "listen": {"foo": [ + {"kind": "tcp", "value": 1}, + {"kind": "udp", "value": 2} + ]}, "connected": null }`) @@ -116,7 +122,7 @@ func TestLoaderBadPorts(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test", "is_remote": true, - "listen": {"foo": ["#/a/b", "@/udp", ""]}, + "listen": {}, "connected": null }`) @@ -149,13 +155,17 @@ func TestLoaderEdges(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test1", - "listen": {"foo": ["1/tcp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "tcp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", - "listen": {"bar":["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/tcp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "tcp", "value": 1}}]} }`) if err := ldr.FromReader(buf); err != nil { @@ -188,8 +198,10 @@ func TestLoaderSeveral(t *testing.T) { if err := ldr.FromReader(bytes.NewBufferString(`{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/tcp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "tcp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} }`)); err != nil { t.Fatal("load1 err=", err) } @@ -197,8 +209,10 @@ func TestLoaderSeveral(t *testing.T) { if err := ldr.FromReader(bytes.NewBufferString(`{ "name": "test2", "networks": ["foo"], - "listen": {"bar":["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/tcp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "tcp", "value": 1}}]} }`)); err != nil { t.Fatal("load2 err=", err) } @@ -229,14 +243,18 @@ func TestLoaderEdgesProto(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "networks": ["foo"], - "listen": {"bar":["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }`) if err := ldr.FromReader(buf); err != nil { @@ -273,14 +291,18 @@ func TestLoaderEdgesFollowNone(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "networks": ["foo"], - "listen": {"bar":["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar":[ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }`) if err := ldr.FromReader(buf); err != nil { @@ -302,26 +324,33 @@ func TestLoaderEdgesFollowOne(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, "connected": { - "test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}], - "test3":[{"src": "foo", "dst": "baz", "port": "3/udp"}] + "test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}], + "test3":[{"src": "foo", "dst": "baz", "port": {"kind": "udp", "value": 3}}] } } { "name": "test2", "networks": ["foo"], - "listen": {"bar":["2/tcp"]}, + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, "connected": { - "test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}] + "test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}] } } { "name": "test3", "networks": ["foo"], - "listen": {"bar":["3/tcp", "3/udp"]}, + "listen": {"bar":[ + {"kind": "tcp", "value": 3}, + {"kind": "udp", "value": 3} + ]}, "connected": { - "test1":[{"src": "baz", "dst": "foo", "port": "1/udp"}] + "test1":[{"src": "baz", "dst": "foo", "port": {"kind": "udp", "value": 1}}] } }`) @@ -359,15 +388,19 @@ func TestLoaderLocal(t *testing.T) { buf := bytes.NewBufferString(`{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "is_external": true, "networks": ["foo"], - "listen": {"bar": ["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} } `) @@ -403,15 +436,19 @@ func TestLoaderMeta(t *testing.T) { "name": "test1", "tags": ["test"], "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "is_external": true, "networks": ["foo"], - "listen": {"bar": ["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }`) bldr := &testBuilder{} @@ -447,15 +484,19 @@ func TestLoaderFull(t *testing.T) { "container": {"cmd": ["foo", "bar"], "env": ["A=B"], "labels": {}}, "volumes": [{"type": "bind", "src": "", "dst": ""}], "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, - "connected": {"test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}]} + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, + "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}]} } { "name": "test2", "is_external": true, "networks": ["foo"], - "listen": {"bar": ["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }`) bldr := &testBuilder{} @@ -489,18 +530,22 @@ func TestLoaderLoops(t *testing.T) { const rawJSON = `{ "name": "test1", "networks": ["foo"], - "listen": {"foo": ["1/udp"]}, + "listen": {"foo": [ + {"kind": "udp", "value": 1} + ]}, "connected": { - "test2":[{"src": "foo", "dst": "bar", "port": "2/tcp"}], - "test1":[{"src": "foo", "dst": "foo", "port": "1/udp"}] + "test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": 2}}], + "test1":[{"src": "foo", "dst": "foo", "port": {"kind": "udp", "value": 1}}] } } { "name": "test2", "is_external": true, "networks": ["foo"], - "listen": {"bar": ["2/tcp"]}, - "connected": {"test1":[{"src": "bar", "dst": "foo", "port": "1/udp"}]} + "listen": {"bar": [ + {"kind": "tcp", "value": 2} + ]}, + "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": 1}}]} }` bldr := &testBuilder{} diff --git a/internal/node/json.go b/internal/node/json.go index 42b2642..7ffa352 100644 --- a/internal/node/json.go +++ b/internal/node/json.go @@ -20,9 +20,9 @@ type Meta struct { } type Connection struct { + Port *Port `json:"port"` Src string `json:"src"` Dst string `json:"dst"` - Port string `json:"port"` } type JSON struct { @@ -33,6 +33,6 @@ type JSON struct { Tags []string `json:"tags"` Volumes []*Volume `json:"volumes"` Container Container `json:"container"` - Listen map[string][]string `json:"listen"` + Listen map[string][]*Port `json:"listen"` Connected map[string][]*Connection `json:"connected"` } diff --git a/internal/node/node.go b/internal/node/node.go index 9779e66..864b802 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -35,7 +35,7 @@ func (n *Node) ToJSON() (rv *JSON) { IsExternal: n.IsExternal(), Networks: n.Networks, Container: n.Container, - Listen: make(map[string][]string), + Listen: make(map[string][]*Port), Volumes: []*Volume{}, Tags: []string{}, Connected: make(map[string][]*Connection), @@ -49,16 +49,10 @@ func (n *Node) ToJSON() (rv *JSON) { rv.Image = &n.Image } - n.Ports.Sort() + n.Ports.Compact() n.Ports.Iter(func(name string, ports []*Port) { - labels := make([]string, len(ports)) - - for i := 0; i < len(ports); i++ { - labels[i] = ports[i].Label() - } - - rv.Listen[name] = labels + rv.Listen[name] = ports }) if len(n.Volumes) > 0 { @@ -109,5 +103,5 @@ func (n *Node) FormatMeta() (rv []string, ok bool) { rv = append(rv, n.Meta.Repo) } - return slices.Clip(rv), true + return slices.Clip(rv), len(rv) > 0 } diff --git a/internal/node/node_test.go b/internal/node/node_test.go index 48f0c66..9ba3df2 100644 --- a/internal/node/node_test.go +++ b/internal/node/node_test.go @@ -255,3 +255,24 @@ func TestNodeToView(t *testing.T) { } } } + +func TestNodeFormatMeta(t *testing.T) { + t.Parallel() + + n := node.Node{} + + if _, ok := n.FormatMeta(); ok { + t.Fail() + } + + n.Meta = &node.Meta{ + Info: "foo", + Docs: "bar", + Repo: "baz", + Tags: []string{"a", "b"}, + } + + if _, ok := n.FormatMeta(); !ok { + t.Fail() + } +} diff --git a/internal/node/port.go b/internal/node/port.go index 345709c..c03e8cb 100644 --- a/internal/node/port.go +++ b/internal/node/port.go @@ -2,56 +2,19 @@ package node import ( "strconv" - "strings" ) -const portMax = 65535 - type Port struct { Kind string `json:"kind"` Value int `json:"value"` + Local bool `json:"local"` } func (p *Port) Label() string { return strconv.Itoa(p.Value) + "/" + p.Kind } -func (p *Port) ID() string { - return p.Kind + strconv.Itoa(p.Value) -} - func (p *Port) Equal(v *Port) (yes bool) { - return p.Kind == v.Kind && p.Value == v.Value -} - -func ParsePort(v string) (rv *Port, ok bool) { - const ( - nparts = 2 - sep = "/" - ) - - parts := strings.SplitN(v, sep, nparts) - if len(parts) != nparts { - return - } - - if parts[0] == "" || parts[1] == "" { - return - } - - iport, err := strconv.Atoi(parts[0]) - if err != nil { - return - } - - if iport < 0 || iport > portMax { - return - } - - rv = &Port{ - Kind: parts[1], - Value: iport, - } - - return rv, true + return p.Kind == v.Kind && + p.Value == v.Value } diff --git a/internal/node/port_test.go b/internal/node/port_test.go index ff37f34..d498fa6 100644 --- a/internal/node/port_test.go +++ b/internal/node/port_test.go @@ -17,7 +17,7 @@ func makeTestPorts(vals ...*node.Port) (rv *node.Ports) { return rv } -func TestPortLabelID(t *testing.T) { +func TestPortLabel(t *testing.T) { t.Parallel() const want = "100" @@ -32,16 +32,6 @@ func TestPortLabelID(t *testing.T) { if !strings.HasSuffix(l, p.Kind) { t.Fail() } - - id := p.ID() - - if !strings.HasSuffix(id, want) { - t.Fail() - } - - if !strings.HasPrefix(id, p.Kind) { - t.Fail() - } } func TestPortsHas(t *testing.T) { @@ -145,21 +135,3 @@ func TestPortsHasAny(t *testing.T) { } } } - -func TestParsePort(t *testing.T) { - t.Parallel() - - cases := []string{ - "", - "1/", - "tcp/1", - "-1/udp", - "66000/tcp", - } - - for _, c := range cases { - if _, ok := node.ParsePort(c); ok { - t.Fail() - } - } -} diff --git a/internal/node/ports.go b/internal/node/ports.go index 483fd2b..d5bed02 100644 --- a/internal/node/ports.go +++ b/internal/node/ports.go @@ -61,8 +61,8 @@ func (ps *Ports) Len() (rv int) { return rv } -func (ps *Ports) Sort() { - for _, pl := range ps.ports { +func (ps *Ports) Compact() { + for k, pl := range ps.ports { slices.SortFunc(pl, func(a, b *Port) int { if a.Kind == b.Kind { return cmp.Compare(a.Value, b.Value) @@ -70,6 +70,10 @@ func (ps *Ports) Sort() { return cmp.Compare(a.Kind, b.Kind) }) + + ps.ports[k] = slices.CompactFunc(pl, func(a, b *Port) bool { + return a.Equal(b) + }) } } diff --git a/internal/structurizr/component.go b/internal/structurizr/component.go new file mode 100644 index 0000000..d559bea --- /dev/null +++ b/internal/structurizr/component.go @@ -0,0 +1,15 @@ +package srtructurizr + +import "io" + +type Component struct { + ID string + Name string + Description string + Technology string + Tags []string +} + +func (c *Component) Write(w io.Writer, level int) { + putCommon(w, level, c.Description, c.Technology, c.Tags) +} diff --git a/internal/structurizr/const.go b/internal/structurizr/const.go index 0d658d0..5118b76 100644 --- a/internal/structurizr/const.go +++ b/internal/structurizr/const.go @@ -7,6 +7,7 @@ const ( keyName = "name" blockContainer = "container" + blockComponent = "component" blockSystemCtx = "systemContext" blockSystem = "softwareSystem" diff --git a/internal/structurizr/container.go b/internal/structurizr/container.go index 4e9a287..ee90a0b 100644 --- a/internal/structurizr/container.go +++ b/internal/structurizr/container.go @@ -10,8 +10,15 @@ type Container struct { Description string Technology string Tags []string + Components []*Component } func (c *Container) Write(w io.Writer, level int) { putCommon(w, level, c.Description, c.Technology, c.Tags) + + for _, com := range c.Components { + putBlock(w, level, blockComponent, com.ID, com.Name) + com.Write(w, level+1) + putEnd(w, level) + } } diff --git a/internal/structurizr/relation.go b/internal/structurizr/relation.go index 843e896..0783e5a 100644 --- a/internal/structurizr/relation.go +++ b/internal/structurizr/relation.go @@ -1,20 +1,7 @@ package srtructurizr -import ( - "fmt" - "io" -) - type Relation struct { - Src string - Dst string - Description string - Technology string - Tags []string -} - -func (r *Relation) Write(w io.Writer, level int) { - desc := fmt.Sprintf("%s to %s %s", r.Src, r.Dst, r.Description) - - putCommon(w, level, desc, r.Technology, r.Tags) + Src string + Dst string + Tags []string } diff --git a/internal/structurizr/system.go b/internal/structurizr/system.go index 112b428..4141c3f 100644 --- a/internal/structurizr/system.go +++ b/internal/structurizr/system.go @@ -1,7 +1,6 @@ package srtructurizr import ( - "cmp" "io" "slices" ) @@ -13,11 +12,12 @@ type System struct { Name string Description string Tags []string + order []string } func NewSystem(name string) *System { return &System{ - ID: safeID(name), + ID: SafeID(name), Name: name, containers: make(map[string]*Container), relationships: make(map[string]map[string]*Relation), @@ -25,24 +25,26 @@ func NewSystem(name string) *System { } func (s *System) AddContainer(id, name string) (c *Container, ok bool) { - id = safeID(id) + id = SafeID(id) if _, ok = s.containers[id]; ok { return nil, false } c = &Container{ - ID: safeID(name), + ID: id, Name: name, } s.containers[id] = c + s.order = append(s.order, id) + slices.Sort(s.order) return c, true } func (s *System) findRelation(src, dst string) (rv *Relation, found bool) { - src, dst = safeID(src), safeID(dst) + src, dst = SafeID(src), SafeID(dst) if dest, ok := s.relationships[src]; ok { if rel, ok := dest[dst]; ok { @@ -60,12 +62,12 @@ func (s *System) findRelation(src, dst string) (rv *Relation, found bool) { } func (s *System) AddRelation(srcID, dstID, srcName, dstName string) (rv *Relation, ok bool) { - src, ok := s.containers[safeID(srcID)] + src, ok := s.containers[SafeID(srcID)] if !ok { return nil, false } - dst, ok := s.containers[safeID(dstID)] + dst, ok := s.containers[SafeID(dstID)] if !ok { return nil, false } @@ -74,7 +76,7 @@ func (s *System) AddRelation(srcID, dstID, srcName, dstName string) (rv *Relatio return rv, true } - srcID, dstID = safeID(src.Name), safeID(dst.Name) + srcID, dstID = SafeID(src.Name), SafeID(dst.Name) dest, ok := s.relationships[srcID] if !ok { @@ -101,28 +103,34 @@ func (s *System) WriteContainers(w io.Writer, level int) { next := level + 1 - contOrder := make([]string, 0, len(s.containers)) + for _, cID := range s.order { + cont := s.containers[cID] - for cID := range s.containers { - contOrder = append(contOrder, cID) + putBlock(w, next, blockContainer, cont.ID, cont.Name) + cont.Write(w, next+1) + putEnd(w, next) } +} - slices.SortFunc(contOrder, cmp.Compare) +func (s *System) WriteViews(w io.Writer, level int) { + next := level + 1 - for _, cID := range contOrder { + for _, cID := range s.order { cont := s.containers[cID] - putBlock(w, next, blockContainer, cont.ID, cont.Name) - cont.Write(w, next+1) - putEnd(w, next) + putView(w, level, blockComponent, cont.ID) + + putRaw(w, next, "include *") + putRaw(w, next, "autoLayout") + + putEnd(w, level) } } func (s *System) WriteRelations(w io.Writer, level int) { for srcID, dest := range s.relationships { for dstID, rel := range dest { - putRelation(w, level, srcID, dstID) - rel.Write(w, level+1) + putRelation(w, level, srcID, dstID, rel.Tags) putEnd(w, level) } } diff --git a/internal/structurizr/utils.go b/internal/structurizr/utils.go index 6b8778e..2588c43 100644 --- a/internal/structurizr/utils.go +++ b/internal/structurizr/utils.go @@ -58,8 +58,13 @@ func putCommon( desc, tech string, tags []string, ) { - putKey(w, level, keyDescription, desc) - putKey(w, level, keyTechnology, tech) + if desc != "" { + putKey(w, level, keyDescription, desc) + } + + if tech != "" { + putKey(w, level, keyTechnology, tech) + } if ctags, ok := compactTags(tags); ok { putKey(w, level, keyTags, strings.Join(ctags, ",")) @@ -80,9 +85,10 @@ func putRelation( w io.Writer, level int, src, dst string, + tags []string, ) { fmt.Fprint(w, strings.Repeat(tab, level)) - fmt.Fprintf(w, "%s -> %s {\n", src, dst) + fmt.Fprintf(w, "%s -> %s \"%s\" {\n", src, dst, strings.Join(tags, ",")) } func putEnd(w io.Writer, level int) { @@ -90,10 +96,10 @@ func putEnd(w io.Writer, level int) { fmt.Fprintln(w, "}") } -func safeID(v string) (id string) { +func SafeID(v string) (id string) { return strings.Map(func(r rune) rune { switch { - case unicode.IsSpace(r), r == '-', r == '.', r == ':': + case unicode.IsSpace(r), unicode.IsPunct(r): return '_' } diff --git a/internal/structurizr/workspace.go b/internal/structurizr/workspace.go index 7d0d8b6..b6cbc2a 100644 --- a/internal/structurizr/workspace.go +++ b/internal/structurizr/workspace.go @@ -20,14 +20,14 @@ func NewWorkspace(name, defaultSystem string) *Workspace { return &Workspace{ Name: name, defaultSystem: defaultSystem, - systemsOrder: []string{safeID(defaultSystem)}, + systemsOrder: []string{SafeID(defaultSystem)}, systems: make(map[string]*System), relationships: make(map[string]map[string]*Relation), } } func (ws *Workspace) System(name string) (rv *System) { - id := safeID(name) + id := SafeID(name) if sys, ok := ws.systems[id]; ok { return sys @@ -45,13 +45,13 @@ func (ws *Workspace) System(name string) (rv *System) { } func (ws *Workspace) HasSystem(name string) (yes bool) { - _, yes = ws.systems[safeID(name)] + _, yes = ws.systems[SafeID(name)] return } func (ws *Workspace) AddRelation(srcID, dstID, srcName, dstName string) (rv *Relation, ok bool) { - srcID, dstID = safeID(srcID), safeID(dstID) + srcID, dstID = SafeID(srcID), SafeID(dstID) if !ws.HasSystem(srcID) || !ws.HasSystem(dstID) { return @@ -80,7 +80,7 @@ func (ws *Workspace) AddRelation(srcID, dstID, srcName, dstName string) (rv *Rel func (ws *Workspace) Write(w io.Writer) { var level int - slices.SortFunc(ws.systemsOrder[1:], cmp.Compare) + slices.Sort(ws.systemsOrder[1:]) putHeader(w, level, headerWorkspace) @@ -145,8 +145,7 @@ func (ws *Workspace) writeRelations(w io.Writer, level int) { for _, dstID := range dstOrder { rel := dest[dstID] - putRelation(w, level, srcID, dstID) - rel.Write(w, level+1) + putRelation(w, level, srcID, dstID, rel.Tags) putEnd(w, level) } } @@ -196,6 +195,8 @@ func (ws *Workspace) writeViews(w io.Writer, level int) { level-- putEnd(w, level) // container + + system.WriteViews(w, level) } fmt.Fprintln(w, "")