├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── dockerhub.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── bin └── .gitignore ├── cmd └── decompose │ └── main.go ├── docker ├── Dockerfile └── README.md ├── examples ├── README.md ├── cluster.json ├── csv2meta.py ├── meta.json ├── snapshots.sh └── stream.json ├── go.mod ├── go.sum └── internal ├── builder ├── builder.go ├── builder_test.go ├── common.go ├── csv.go ├── csv_test.go ├── dot.go ├── dot_test.go ├── json.go ├── json_test.go ├── main_test.go ├── puml.go ├── puml_test.go ├── sdsl.go ├── sdsl_test.go ├── stat.go ├── stat_test.go ├── testdata │ ├── compose-yaml.golden │ ├── csv.golden │ ├── graphviz-dot.golden │ ├── plant-uml.golden │ ├── structurizr-dsl.golden │ └── text-tree.golden ├── tree.go ├── tree_test.go ├── yaml.go └── yaml_test.go ├── client ├── defaults.go ├── docker.go ├── docker_test.go ├── helpers_test.go ├── inodes.go ├── inodes_test.go ├── mode.go ├── mode_test.go └── options.go ├── cluster ├── graph.go ├── groups.go ├── groups_test.go ├── layers.go ├── layers_test.go ├── node.go ├── node_test.go ├── rules.go └── rules_test.go ├── graph ├── build.go ├── build_state.go ├── build_test.go ├── compress.go ├── compress_test.go ├── config.go ├── connection.go ├── connection_test.go ├── conngroup.go ├── conngroup_test.go ├── container.go ├── container_test.go ├── load.go ├── load_test.go ├── meta.go ├── meta_test.go ├── netproto.go ├── netproto_test.go ├── netstat.go ├── netstat_test.go ├── orphans.go └── orphans_test.go ├── node ├── edge.go ├── json.go ├── node.go ├── node_test.go ├── port.go ├── port_test.go ├── ports.go ├── ports_test.go └── view.go └── structurizr ├── component.go ├── const.go ├── container.go ├── relation.go ├── system.go ├── system_test.go ├── utils.go ├── workspace.go └── workspace_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | *.out 2 | *.md 3 | ./bin 4 | ./dist 5 | ./examples 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # dependabot settings 2 | version: 2 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: / 6 | target-branch: master 7 | labels: 8 | - gomod 9 | schedule: 10 | interval: weekly 11 | day: friday 12 | - package-ecosystem: github-actions 13 | directory: / 14 | target-branch: master 15 | labels: 16 | - github-actions 17 | schedule: 18 | interval: weekly 19 | day: friday 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # ci workflow 2 | name: ci 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | environment: 16 | name: ci 17 | steps: 18 | - name: checkout 19 | uses: actions/checkout@v4 20 | - name: setup golang 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ^1.23 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v6 26 | - name: goreleaser-check 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | version: latest 30 | args: check -f .goreleaser.yml 31 | test: 32 | runs-on: ubuntu-latest 33 | environment: 34 | name: ci 35 | steps: 36 | - name: checkout 37 | uses: actions/checkout@v4 38 | - name: setup golang 39 | uses: actions/setup-go@v5 40 | with: 41 | go-version: ^1.23 42 | - name: tests-only 43 | if: ${{ github.event_name == 'pull_request' }} 44 | run: make test 45 | - name: test-coverage 46 | if: ${{ github.event_name == 'push' }} 47 | uses: paambaati/codeclimate-action@v9.0.0 48 | env: 49 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 50 | with: 51 | coverageCommand: make test 52 | coverageLocations: ${{ github.workspace }}/cover.out:gocov 53 | prefix: github.com/${{ github.repository }} 54 | codeql: 55 | if: ${{ github.event_name == 'push' }} 56 | runs-on: ubuntu-latest 57 | needs: [lint, test] 58 | environment: 59 | name: ci 60 | steps: 61 | - name: checkout 62 | uses: actions/checkout@v4 63 | - name: setup golang 64 | uses: actions/setup-go@v5 65 | with: 66 | go-version: ^1.23 67 | - name: init codeql 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: 'go' 71 | - name: run analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: dockerhub 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update_registry: 9 | name: build and push image 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | steps: 14 | - name: Log in 15 | uses: docker/login-action@v3 16 | with: 17 | username: ${{ github.actor }} 18 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 19 | - name: Check out 20 | uses: actions/checkout@v4 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | - name: Build and push 24 | uses: docker/build-push-action@v6 25 | with: 26 | context: . 27 | file: ./docker/Dockerfile 28 | platforms: linux/amd64 29 | tags: | 30 | ${{ github.repository }}:${{ github.ref_name }} 31 | ${{ github.repository }}:latest 32 | push: true 33 | - name: Update readme 34 | uses: peter-evans/dockerhub-description@v4 35 | with: 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 38 | repository: ${{ github.repository }} 39 | readme-filepath: ./docker/README.md 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # release workflow 2 | name: release 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v4 18 | - name: set up golang 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ^1.23 22 | - name: build 23 | uses: goreleaser/goreleaser-action@v6 24 | with: 25 | version: latest 26 | args: release -f .goreleaser.yml --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 29 | reportcard: 30 | runs-on: ubuntu-latest 31 | needs: release 32 | steps: 33 | - name: update goreportcard 34 | uses: creekorful/goreportcard-action@v1.0 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | vendor/ 3 | .structurizr/ 4 | workspace.* 5 | /*.json 6 | *.out 7 | *.png 8 | *.svg 9 | *.dot 10 | *.dsl 11 | *.pprof 12 | *_profile.pdf 13 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | allow-parallel-runners: true 3 | 4 | output: 5 | print-issued-lines: true 6 | print-linter-name: true 7 | sort-results: true 8 | 9 | linters: 10 | enable-all: true 11 | disable: 12 | - gochecknoglobals 13 | - nonamedreturns 14 | - tagliatelle 15 | - exhaustruct 16 | - inamedparam 17 | - exhaustive 18 | - varnamelen 19 | - depguard 20 | - intrange 21 | - ireturn 22 | - gofumpt 23 | - gci 24 | 25 | linters-settings: 26 | errcheck: 27 | check-type-assertions: true 28 | govet: 29 | enable-all: true 30 | funlen: 31 | lines: 80 32 | ignore-comments: true 33 | cyclop: 34 | max-complexity: 13 35 | skip-tests: true 36 | gocritic: 37 | enabled-tags: 38 | - performance 39 | - opinionated 40 | - diagnostic 41 | - style 42 | 43 | issues: 44 | exclude-rules: 45 | - path: cmd/decompose/main\.go 46 | linters: 47 | - forbidigo 48 | - path: internal/builder/json\.go 49 | linters: 50 | - errchkjson 51 | - path: internal/node/json\.go 52 | text: "fieldalignment" 53 | linters: 54 | - govet # fieldalignment issue, ignored for json readability 55 | - path: internal/builder/yaml\.go 56 | text: "fieldalignment" 57 | linters: 58 | - govet # fieldalignment issue, ignored for yaml readability 59 | - path: internal/client/defaults\.go 60 | text: "G115" # uint56 -> int overflow, impossible for ip ports ranges 61 | linters: 62 | - gosec 63 | - path: internal/graph/netstat\.go 64 | text: "G115" # uint56 -> int overflow, impossible for ip ports ranges 65 | linters: 66 | - gosec 67 | - path: ._test\.go 68 | linters: 69 | - gocritic 70 | - errcheck 71 | - maintidx 72 | - err113 73 | - funlen 74 | - dupl 75 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | project_name: decompose 3 | 4 | before: 5 | hooks: 6 | - go mod download 7 | 8 | builds: 9 | - id: decompose-bin 10 | dir: . 11 | main: ./cmd/decompose 12 | env: [CGO_ENABLED=0] 13 | flags: 14 | - -trimpath 15 | ldflags: 16 | - -s -w 17 | - -X main.GitTag={{.Tag}} 18 | - -X main.GitHash={{.ShortCommit}} 19 | - -X main.BuildDate={{.Date}} 20 | goos: 21 | - linux 22 | - darwin 23 | - windows 24 | - freebsd 25 | goarch: 26 | - amd64 27 | - arm64 28 | ignore: 29 | - goos: windows 30 | goarch: arm64 31 | 32 | archives: 33 | - id: decompose 34 | format: tar.gz 35 | builds: 36 | - decompose-bin 37 | name_template: >- 38 | {{ .ProjectName }}_ 39 | {{- .Tag }}_ 40 | {{- .Os }}_ 41 | {{- if eq .Arch "amd64" }}x86_64 42 | {{- else if eq .Arch "386" }}i386 43 | {{- else }}{{ .Arch }}{{ end }} 44 | format_overrides: 45 | - goos: windows 46 | format: zip 47 | 48 | nfpms: 49 | - id: packages 50 | file_name_template: >- 51 | {{ .ProjectName }}_ 52 | {{- .Tag }}_ 53 | {{- .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }} 54 | homepage: https://github.com/s0rg/decompose 55 | description: Reverse-engineering tool for docker environments. 56 | maintainer: s0rg 57 | license: MIT 58 | vendor: decompose 59 | formats: 60 | - deb 61 | - rpm 62 | 63 | changelog: 64 | filters: 65 | exclude: 66 | - '^chore' 67 | - '^docs' 68 | - '^refactor' 69 | - '^style' 70 | - '^test' 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Decompose 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## We Develop with Github 12 | 13 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 14 | 15 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 16 | 17 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). 18 | We actively welcome your pull requests: 19 | 20 | 1. Fork the repo and create your branch from `master` (e.g. feature/my-cool-feature or bugfix/issue-info) 21 | 1. If you've added code that should be tested, add tests. 22 | 1. If you've changed APIs, update the documentation. 23 | 1. Ensure the test suite passes. 24 | 1. Make sure your code lints. 25 | 1. Squash your commits. 26 | 1. Issue that pull request! 27 | 28 | ## Any contributions you make will be under the MIT Software License 29 | 30 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 31 | 32 | ## Report bugs using Github's [issues](https://github.com/s0rg/decompose/issues) 33 | 34 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/s0rg/decompose/issues/new/choose); it's that easy! 35 | 36 | ## Write bug reports with detail, background, and sample code 37 | 38 | **Great Bug Reports** tend to have: 39 | 40 | - A quick summary and/or background 41 | - Steps to reproduce 42 | - Be specific! 43 | - Give sample code if you can. 44 | - What you expected would happen 45 | - What actually happens 46 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 47 | 48 | ## Use a Consistent Coding Style 49 | 50 | - `go fmt` your code 51 | 52 | ## License 53 | 54 | By contributing, you agree that your contributions will be licensed under its MIT License. 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexei Shevchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/usr/bin/env bash 2 | 3 | BIN=bin/decompose 4 | COP=cover.out 5 | 6 | CMD=./cmd/decompose 7 | ALL=./... 8 | 9 | GIT_TAG=`git describe --abbrev=0 2>/dev/null || echo -n "no-tag"` 10 | GIT_HASH=`git rev-parse --short HEAD 2>/dev/null || echo -n "no-git"` 11 | BUILD_AT=`date +%FT%T%z` 12 | 13 | LDFLAGS=-w -s \ 14 | -X main.buildDate=${BUILD_AT} \ 15 | -X main.gitVersion=${GIT_TAG} \ 16 | -X main.gitHash=${GIT_HASH} 17 | 18 | export CGO_ENABLED=0 19 | 20 | .PHONY: build 21 | build: vet 22 | @go build -trimpath -ldflags "${LDFLAGS}" -o "${BIN}" "${CMD}" 23 | 24 | .PHONY: vet 25 | vet: 26 | @go vet "${ALL}" 27 | 28 | .PHONY: test 29 | test: vet 30 | @CGO_ENABLED=1 go test -v -race -count 1 -tags=test \ 31 | -cover -coverpkg="${ALL}" -coverprofile="${COP}" \ 32 | "${ALL}" 33 | 34 | .PHONY: test-update 35 | test-update: test-clean 36 | @echo "updating golden files..." 37 | @go test -tags=test "./internal/builder" -update 38 | 39 | .PHONY: test-clean 40 | test-clean: 41 | @echo "clean-up..." 42 | @find . -name "*.golden" -delete 43 | 44 | .PHONY: test-cover 45 | test-cover: test 46 | @go tool cover -func="${COP}" 47 | 48 | .PHONY: lint 49 | lint: vet 50 | @golangci-lint run 51 | 52 | .PHONY: markdown-fix 53 | markdown-fix: 54 | # https://github.com/executablebooks/mdformat 55 | mdformat . 56 | 57 | .PHONY: clean 58 | clean: 59 | [ -f "${BIN}" ] && rm "${BIN}" 60 | [ -f "${COP}" ] && rm "${COP}" 61 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.10.x | :white_check_mark: | 8 | | \< 1.10 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Please create an issue with vulnerability description and example. 13 | -------------------------------------------------------------------------------- /bin/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | ADD .. /go/src/github.com/s0rg/decompose 4 | WORKDIR /go/src/github.com/s0rg/decompose 5 | 6 | RUN make build 7 | 8 | FROM scratch 9 | 10 | ARG BUILD_DATE 11 | ARG BUILD_REV 12 | 13 | COPY --from=builder /go/src/github.com/s0rg/decompose/bin/decompose /decompose 14 | 15 | ENTRYPOINT ["/decompose"] 16 | 17 | LABEL org.opencontainers.image.created="${BUILD_DATE}" \ 18 | org.opencontainers.image.revision="${BUILD_REV}" \ 19 | org.opencontainers.image.title="decompose" \ 20 | org.opencontainers.image.authors="s0rg" \ 21 | org.opencontainers.image.vendor="s0rg" \ 22 | org.opencontainers.image.source="https://github.com/s0rg/decompose" 23 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # decompose 2 | 3 | Reverse-engineering tool for docker environments. 4 | 5 | # how to run 6 | 7 | # scan containers 8 | ``` 9 | docker run \ 10 | -v /var/run/docker.sock:/var/run/docker.sock \ 11 | -v /:/rootfs:ro \ 12 | -e IN_DOCKER_PROC_ROOT=/rootfs \ 13 | s0rg/decompose:latest > mystream.json 14 | ``` 15 | 16 | # process results 17 | ``` 18 | docker run \ 19 | s0rg/decompose:latest -load mystream.json -format sdsl > workspace.dsl 20 | ``` 21 | 22 | 23 | [more options and documentaion](https://github.com/s0rg/decompose) 24 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # examples 2 | 3 | ## example json files 4 | 5 | - `stream.json` - simple system as json stream example 6 | - `cluster.json` - clusterization rules example 7 | - `meta.json` - metadata example 8 | 9 | usage: 10 | 11 | ```shell 12 | decompose -cluster cluster.json -meta meta.json -load stream.json -format dot | dot -Tsvg > example.svg 13 | ``` 14 | 15 | ## csv2meta script 16 | 17 | example script to convert any compatible csv - at least 3 columns, with `id, info, docs, repo, tags` order and comma as delimeter 18 | to metadata json for decomposer 19 | 20 | ## usage 21 | 22 | ```shell 23 | python3 csv2meta.py my_meta_utf8.csv > meta.json 24 | ``` 25 | 26 | ## snapshots.sh script 27 | 28 | example script for taking and merging snapshots, writes result to `merged.json` 29 | -------------------------------------------------------------------------------- /examples/cluster.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "external", 4 | "if": "node.IsExternal", 5 | "weight": 3 6 | }, 7 | { 8 | "name": "ingress", 9 | "if": "node.Listen.HasAny('tcp:80', 'tcp:443')" 10 | }, 11 | { 12 | "name": "backend", 13 | "if": "node.Name startsWith 'back' && node.Listen.Has('tcp:8080', 'tcp:8081')", 14 | "weight": 2 15 | }, 16 | { 17 | "name": "store", 18 | "if": "node.Listen.HasAny('tcp:3306', 'tcp:5432')" 19 | }, 20 | { 21 | "name": "redis", 22 | "if": "node.Listen.Has('tcp:6379')" 23 | }, 24 | { 25 | "name": "queue", 26 | "if": "node.Listen.HasAny('tcp:9092', 'tcp:4222')" 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /examples/csv2meta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import csv 5 | import json 6 | import sys 7 | 8 | parser = argparse.ArgumentParser( 9 | description='csv2meta converter for decompose') 10 | parser.add_argument( 11 | '--skip', 12 | type=int, 13 | default=1, 14 | help='header lines to skip (default: 1)', 15 | ) 16 | parser.add_argument( 17 | 'csv', 18 | type=str, 19 | help= 20 | 'csv for convertion, with 5 columns (at least): key, info, docs, repo, tags' 21 | ) 22 | 23 | 24 | def main(): 25 | args = parser.parse_args() 26 | state = {} 27 | 28 | with open(args.csv, newline='') as fd: 29 | for n, row in enumerate(csv.reader(fd)): 30 | if n < args.skip: 31 | continue 32 | key, info, docs, repo, tags = row[:5] 33 | skey = key.strip() 34 | if not skey: 35 | continue 36 | state[skey] = { 37 | 'info': info.strip(), 38 | 'docs': docs.strip(), 39 | 'repo': repo.strip(), 40 | 'tags': list(filter(bool, tags.split(','))), 41 | } 42 | 43 | json.dump( 44 | state, 45 | sys.stdout, 46 | sort_keys=True, 47 | indent=4, 48 | ensure_ascii=False, 49 | ) 50 | 51 | 52 | main() 53 | -------------------------------------------------------------------------------- /examples/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "nginx": { 3 | "info": "nginx reverse-proxy", 4 | "docs": "https://nginx.org/en/docs/", 5 | "repo": "https://trac.nginx.org/nginx", 6 | "tags": ["http"] 7 | }, 8 | "back": { 9 | "info": "backend service", 10 | "docs": "https://acme.corp/internal/docs/api", 11 | "repo": "https://git.acme.corp/backend/api", 12 | "tags": ["api", "backend"] 13 | }, 14 | "db": { 15 | "info": "postgresql", 16 | "docs": "https://www.postgresql.org/docs/15/index.html", 17 | "repo": "https://git.postgresql.org/gitweb/?p=postgresql.git;a=summary", 18 | "tags": ["store", "sql"] 19 | }, 20 | "foo": { 21 | "info": "foo service", 22 | "docs": "https://acme.corp/internal/docs/foo", 23 | "repo": "https://git.acme.corp/backend-legacy/foo", 24 | "tags": ["legacy"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/snapshots.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # working directory for snapshots 4 | OUT=snapshots 5 | # number of snapshots to take 6 | COUNT=5 7 | # wait time (in seconds) between each snapshot 8 | WAIT=10 9 | 10 | mkdir -p "${OUT}" 11 | 12 | for i in $(seq 1 ${COUNT}); do 13 | echo "Taking snapshot ${i}..." 14 | 15 | decompose -out "${OUT}/snapshot_${i}.json" 16 | 17 | if [[ "${i}" -ne "${COUNT}" ]]; then 18 | echo "Sleeping for ${WAIT} seconds..." 19 | sleep "${WAIT}" 20 | fi 21 | done 22 | 23 | echo "Merging..." 24 | 25 | decompose -load "${OUT}/*.json" -out merged.json 26 | 27 | echo "Cleaning-up..." 28 | 29 | rm -rf "${OUT}" 30 | -------------------------------------------------------------------------------- /examples/stream.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nginx1", 3 | "listen": {"nginx": [{"kind": "tcp", "value": "80"}]}, 4 | "connected": { 5 | "back1": [{"src": "nginx", "dst": "app", "port": {"kind": "tcp", "value": "8080"}}], 6 | "back2": [{"src": "nginx", "dst": "app", "port": {"kind": "tcp", "value": "8081"}}] 7 | } 8 | } 9 | { 10 | "name": "db1", 11 | "listen": {"postgres": [{"kind": "tcp", "value": "5432"}]}, 12 | "connected": {} 13 | } 14 | { 15 | "name": "back1", 16 | "listen": {"app": [ 17 | {"kind": "tcp", "value": "8080"}, 18 | {"kind": "tcp", "value": "8081"}, 19 | {"kind": "tcp", "value": "9000"} 20 | ]}, 21 | "connected": { 22 | "db1": [{"src": "app", "dst": "postgres", "port": {"kind": "tcp", "value": "5432"}}] 23 | } 24 | } 25 | { 26 | "name": "back2", 27 | "listen": {"app": [ 28 | {"kind": "tcp", "value": "8080"}, 29 | {"kind": "tcp", "value": "8081"} 30 | ]}, 31 | "connected": { 32 | "db1": [{"src": "app", "dst": "postgres", "port": {"kind": "tcp", "value": "5432"}}], 33 | "foo1": [{"src": "app", "dst": "[remote]", "port": {"kind": "tcp", "value": "9500"}}] 34 | } 35 | } 36 | { 37 | "name": "foo1", 38 | "is_external": true, 39 | "listen": {"[remote]": [{"kind": "tcp", "value": "9500"}]}, 40 | "connected": { 41 | "back1": [ 42 | {"src": "[remote]", "dst": "app", "port": {"kind": "tcp", "value": "9000"}} 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/s0rg/decompose 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/docker/docker v27.3.1+incompatible 7 | github.com/emicklei/dot v1.6.2 8 | github.com/expr-lang/expr v1.16.9 9 | github.com/prometheus/procfs v0.15.1 10 | github.com/s0rg/set v1.2.1 11 | github.com/s0rg/trie v1.3.3 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/Microsoft/go-winio v0.6.2 // indirect 17 | github.com/containerd/log v0.1.0 // indirect 18 | github.com/distribution/reference v0.6.0 // indirect 19 | github.com/docker/go-connections v0.5.0 // indirect 20 | github.com/docker/go-units v0.5.0 // indirect 21 | github.com/felixge/httpsnoop v1.0.4 // indirect 22 | github.com/go-logr/logr v1.4.2 // indirect 23 | github.com/go-logr/stdr v1.2.2 // indirect 24 | github.com/gogo/protobuf v1.3.2 // indirect 25 | github.com/kr/pretty v0.3.0 // indirect 26 | github.com/moby/docker-image-spec v1.3.1 // indirect 27 | github.com/moby/term v0.5.0 // indirect 28 | github.com/morikuni/aec v1.0.0 // indirect 29 | github.com/opencontainers/go-digest v1.0.0 // indirect 30 | github.com/opencontainers/image-spec v1.1.0 // indirect 31 | github.com/pkg/errors v0.9.1 // indirect 32 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect 33 | go.opentelemetry.io/otel v1.30.0 // indirect 34 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect 35 | go.opentelemetry.io/otel/metric v1.30.0 // indirect 36 | go.opentelemetry.io/otel/sdk v1.22.0 // indirect 37 | go.opentelemetry.io/otel/trace v1.30.0 // indirect 38 | golang.org/x/net v0.24.0 // indirect 39 | golang.org/x/sys v0.26.0 // indirect 40 | golang.org/x/time v0.3.0 // indirect 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 42 | gotest.tools/v3 v3.5.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /internal/builder/builder.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "github.com/s0rg/decompose/internal/graph" 5 | ) 6 | 7 | const ( 8 | KindCSV = "csv" 9 | KindDOT = "dot" 10 | KindJSON = "json" 11 | KindTREE = "tree" 12 | KindYAML = "yaml" 13 | KindSTAT = "stat" 14 | KindStructurizr = "sdsl" 15 | KindPlantUML = "puml" 16 | ) 17 | 18 | var Names = []string{ 19 | KindCSV, 20 | KindDOT, 21 | KindJSON, 22 | KindTREE, 23 | KindYAML, 24 | KindSTAT, 25 | KindStructurizr, 26 | KindPlantUML, 27 | } 28 | 29 | func Create(kind string) (b graph.NamedBuilderWriter, ok bool) { 30 | switch kind { 31 | case KindCSV: 32 | return NewCSV(), true 33 | case KindDOT: 34 | return NewDOT(), true 35 | case KindJSON: 36 | return NewJSON(), true 37 | case KindStructurizr: 38 | return NewStructurizr(), true 39 | case KindTREE: 40 | return NewTree(), true 41 | case KindYAML: 42 | return NewYAML(), true 43 | case KindSTAT: 44 | return NewStat(), true 45 | case KindPlantUML: 46 | return NewPlantUML(), true 47 | } 48 | 49 | return 50 | } 51 | 52 | func SupportCluster(n string) (yes bool) { 53 | switch n { 54 | case KindStructurizr, KindSTAT, KindDOT, KindPlantUML: 55 | return true 56 | } 57 | 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /internal/builder/builder_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/builder" 7 | ) 8 | 9 | func TestCreate(t *testing.T) { 10 | t.Parallel() 11 | 12 | if _, ok := builder.Create(""); ok { 13 | t.Fail() 14 | } 15 | 16 | for _, name := range builder.Names { 17 | if _, ok := builder.Create(name); !ok { 18 | t.Fail() 19 | } 20 | } 21 | } 22 | 23 | func TestSupportCluster(t *testing.T) { 24 | t.Parallel() 25 | 26 | does := []string{ 27 | builder.KindDOT, 28 | builder.KindSTAT, 29 | builder.KindStructurizr, 30 | builder.KindPlantUML, 31 | } 32 | 33 | doesnt := []string{ 34 | builder.KindJSON, 35 | builder.KindTREE, 36 | builder.KindYAML, 37 | } 38 | 39 | for _, k := range does { 40 | if !builder.SupportCluster(k) { 41 | t.Fail() 42 | } 43 | } 44 | 45 | for _, k := range doesnt { 46 | if builder.SupportCluster(k) { 47 | t.Fail() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/builder/common.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/s0rg/decompose/internal/node" 8 | ) 9 | 10 | func joinConnections(conns []*node.Connection, sep string) (rv string) { 11 | tmp := make([]string, 0, len(conns)) 12 | 13 | for _, c := range conns { 14 | tmp = append(tmp, c.Port.Label()) 15 | } 16 | 17 | slices.Sort(tmp) 18 | tmp = slices.Compact(tmp) 19 | 20 | return strings.Join(tmp, sep) 21 | } 22 | 23 | func joinListeners(ports map[string][]*node.Port, sep string) (rv string) { 24 | var tmp []string 25 | 26 | for _, plist := range ports { 27 | for _, p := range plist { 28 | tmp = append(tmp, p.Label()) 29 | } 30 | } 31 | 32 | slices.Sort(tmp) 33 | tmp = slices.Compact(tmp) 34 | 35 | return strings.Join(tmp, sep) 36 | } 37 | -------------------------------------------------------------------------------- /internal/builder/csv.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "io" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | var csvHeader = []string{ 14 | "service", "listen", "outbounds", 15 | } 16 | 17 | type CSV struct { 18 | j *JSON 19 | } 20 | 21 | func NewCSV() *CSV { 22 | return &CSV{ 23 | j: NewJSON(), 24 | } 25 | } 26 | 27 | func (c *CSV) Name() string { 28 | return "csv" 29 | } 30 | 31 | func (c *CSV) AddNode(n *node.Node) error { 32 | return c.j.AddNode(n) 33 | } 34 | 35 | func (c *CSV) AddEdge(e *node.Edge) { 36 | c.j.AddEdge(e) 37 | } 38 | 39 | func (c *CSV) Write(w io.Writer) error { 40 | cw := csv.NewWriter(w) 41 | cw.UseCRLF = true 42 | 43 | _ = cw.Write(csvHeader) 44 | 45 | c.j.Sorted(func(n *node.JSON, _ bool) { 46 | _ = cw.Write([]string{ 47 | n.Name, 48 | joinListeners(n.Listen, "\r\n"), 49 | renderOutbounds(n.Connected), 50 | }) 51 | }) 52 | 53 | cw.Flush() 54 | 55 | if err := cw.Error(); err != nil { 56 | return fmt.Errorf("fail: %w", err) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func renderOutbounds(conns map[string][]*node.Connection) (rv string) { 63 | var b strings.Builder 64 | 65 | order := make([]string, 0, len(conns)) 66 | 67 | for k := range conns { 68 | order = append(order, k) 69 | } 70 | 71 | slices.Sort(order) 72 | 73 | for _, k := range order { 74 | v := conns[k] 75 | 76 | b.WriteString(k) 77 | b.WriteString(": ") 78 | b.WriteString(joinConnections(v, "; ")) 79 | b.WriteString("\r\n") 80 | } 81 | 82 | return b.String() 83 | } 84 | -------------------------------------------------------------------------------- /internal/builder/csv_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/builder" 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | func TestCSVGolden(t *testing.T) { 13 | t.Parallel() 14 | 15 | bld := builder.NewCSV() 16 | 17 | _ = bld.AddNode(&node.Node{ 18 | ID: "node-1", 19 | Name: "1", 20 | Image: "node-image", 21 | Ports: makeTestPorts( 22 | []*node.Port{ 23 | {Kind: "tcp", Value: "1"}, 24 | {Kind: "tcp", Value: "2"}, 25 | }...), 26 | Networks: []string{"test-net"}, 27 | Meta: &node.Meta{ 28 | Info: "info 1", 29 | Tags: []string{"1"}, 30 | }, 31 | Container: node.Container{ 32 | Cmd: []string{"echo", "'test 1'"}, 33 | Env: []string{"FOO=1"}, 34 | }, 35 | }) 36 | _ = bld.AddNode(&node.Node{ 37 | ID: "node-2", 38 | Name: "2", 39 | Ports: makeTestPorts([]*node.Port{ 40 | {Kind: "tcp", Value: "2"}, 41 | }...), 42 | Networks: []string{"test-net"}, 43 | Meta: &node.Meta{ 44 | Info: "info 2", 45 | Tags: []string{"2"}, 46 | }, 47 | Container: node.Container{ 48 | Cmd: []string{"echo", "'test 2'"}, 49 | Env: []string{"FOO=2"}, 50 | }, 51 | }) 52 | _ = bld.AddNode(&node.Node{ 53 | ID: "node-3", 54 | Name: "3", 55 | Ports: makeTestPorts([]*node.Port{ 56 | {Kind: "tcp", Value: "3"}, 57 | }...), 58 | Networks: []string{"test-net"}, 59 | Meta: &node.Meta{ 60 | Info: "info 3", 61 | Tags: []string{"3"}, 62 | }, 63 | Container: node.Container{ 64 | Cmd: []string{"echo", "'test 3'"}, 65 | Env: []string{"FOO=3"}, 66 | }, 67 | }) 68 | 69 | bld.AddEdge(&node.Edge{ 70 | SrcID: "node-2", 71 | DstID: "node-1", 72 | Port: &node.Port{Kind: "tcp", Value: "1"}, 73 | }) 74 | 75 | bld.AddEdge(&node.Edge{ 76 | SrcID: "node-2", 77 | DstID: "node-1", 78 | Port: &node.Port{Kind: "tcp", Value: "2"}, 79 | }) 80 | 81 | bld.AddEdge(&node.Edge{ 82 | SrcID: "node-2", 83 | DstID: "node-1", 84 | Port: &node.Port{Kind: "tcp", Value: "3"}, 85 | }) 86 | 87 | bld.AddEdge(&node.Edge{ 88 | SrcID: "node-2", 89 | DstID: "node-3", 90 | Port: &node.Port{Kind: "tcp", Value: "3"}, 91 | }) 92 | 93 | bld.AddEdge(&node.Edge{ 94 | SrcID: "node-1", 95 | DstID: "node-3", 96 | Port: &node.Port{Kind: "tcp", Value: "3"}, 97 | }) 98 | 99 | bld.AddEdge(&node.Edge{ 100 | SrcID: "node-1", 101 | DstID: "node-2", 102 | Port: &node.Port{Kind: "tcp", Value: "2"}, 103 | }) 104 | 105 | bld.AddEdge(&node.Edge{ 106 | SrcID: "node-1", 107 | DstID: "3", 108 | Port: &node.Port{Kind: "tcp", Value: "3"}, 109 | }) 110 | 111 | bld.AddEdge(&node.Edge{ 112 | SrcID: "3", 113 | DstID: "node-1", 114 | Port: &node.Port{Kind: "tcp", Value: "3"}, 115 | }) 116 | 117 | var buf bytes.Buffer 118 | 119 | bld.Write(&buf) 120 | 121 | got := buf.String() 122 | want := golden(t, bld.Name(), got) 123 | 124 | if got != want { 125 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 126 | } 127 | } 128 | 129 | func TestCSVWriteError(t *testing.T) { 130 | t.Parallel() 131 | 132 | bldr := builder.NewCSV() 133 | testErr := errors.New("test-error") 134 | errW := &errWriter{Err: testErr} 135 | 136 | _ = bldr.AddNode(&node.Node{ 137 | ID: "#", 138 | Name: "#", 139 | Image: "node-image", 140 | Ports: &node.Ports{}, 141 | Networks: []string{"test-net"}, 142 | Meta: &node.Meta{ 143 | Info: "info 1", 144 | Tags: []string{"1"}, 145 | }, 146 | Container: node.Container{ 147 | Cmd: []string{"echo", "'test 1'"}, 148 | Env: []string{"FOO=1"}, 149 | }, 150 | Volumes: []*node.Volume{ 151 | {Type: "volume", Src: "src", Dst: "dst"}, 152 | }, 153 | }) 154 | 155 | if err := bldr.Write(errW); !errors.Is(err, testErr) { 156 | t.Log(err) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /internal/builder/dot.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "io" 5 | "slices" 6 | 7 | "github.com/emicklei/dot" 8 | 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | type DOT struct { 13 | g *dot.Graph 14 | edges map[string]map[string][]string 15 | } 16 | 17 | func NewDOT() *DOT { 18 | g := dot.NewGraph(dot.Directed) 19 | 20 | return &DOT{ 21 | g: g, 22 | edges: make(map[string]map[string][]string), 23 | } 24 | } 25 | 26 | func (d *DOT) Name() string { 27 | return "graphviz-dot" 28 | } 29 | 30 | func (d *DOT) AddNode(n *node.Node) error { 31 | label, color := renderNode(n) 32 | 33 | d.g.Node(n.ID).Attr( 34 | "color", color, 35 | ).Label(label) 36 | 37 | return nil 38 | } 39 | 40 | func (d *DOT) AddEdge(e *node.Edge) { 41 | if _, ok := d.g.FindNodeById(e.SrcID); !ok { 42 | return 43 | } 44 | 45 | if _, ok := d.g.FindNodeById(e.DstID); !ok { 46 | return 47 | } 48 | 49 | d.addEdge(e.SrcID, e.DstID, e.Port.Label()) 50 | } 51 | 52 | func (d *DOT) addEdge(src, dst, label string) { 53 | dmap, ok := d.edges[src] 54 | if !ok { 55 | dmap = make(map[string][]string) 56 | d.edges[src] = dmap 57 | } 58 | 59 | dmap[dst] = append(dmap[dst], label) 60 | } 61 | 62 | func (d *DOT) buildEdges() { 63 | order := make([]string, 0, len(d.edges)) 64 | 65 | for k := range d.edges { 66 | order = append(order, k) 67 | } 68 | 69 | slices.Sort(order) 70 | 71 | for _, srcID := range order { 72 | src, _ := d.g.FindNodeById(srcID) 73 | 74 | dmap := d.edges[srcID] 75 | dorder := make([]string, 0, len(dmap)) 76 | 77 | for k := range dmap { 78 | dorder = append(dorder, k) 79 | } 80 | 81 | slices.Sort(dorder) 82 | 83 | for _, dstID := range dorder { 84 | dst, _ := d.g.FindNodeById(dstID) 85 | ports := dmap[dstID] 86 | 87 | if tmp, ok := d.edges[dstID]; ok { 88 | if dports, ok := tmp[srcID]; ok { 89 | ports = append(ports, dports...) 90 | 91 | delete(tmp, srcID) 92 | } 93 | } 94 | 95 | d.g.Edge(src, dst, ports...) 96 | } 97 | } 98 | } 99 | 100 | func (d *DOT) Write(w io.Writer) error { 101 | d.buildEdges() 102 | d.g.Write(w) 103 | 104 | return nil 105 | } 106 | 107 | func renderNode(n *node.Node) (label, color string) { 108 | label, color = n.Name, "black" 109 | 110 | if n.IsExternal() { 111 | color = "gray" 112 | label = "external: " + n.Name 113 | } 114 | 115 | return label, color 116 | } 117 | -------------------------------------------------------------------------------- /internal/builder/dot_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/s0rg/decompose/internal/builder" 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | func TestDOTGolden(t *testing.T) { 12 | t.Parallel() 13 | 14 | bld := builder.NewDOT() 15 | 16 | _ = bld.AddNode(&node.Node{ 17 | ID: "node-1", 18 | Name: "1", 19 | Image: "node-image", 20 | Cluster: "c1", 21 | Ports: makeTestPorts([]*node.Port{ 22 | {Kind: "tcp", Value: "1"}, 23 | {Kind: "tcp", Value: "2"}, 24 | }...), 25 | Networks: []string{"test-net"}, 26 | Meta: &node.Meta{ 27 | Info: "info 1", 28 | Docs: "docs-url", 29 | Repo: "repo-url", 30 | Tags: []string{"1"}, 31 | }, 32 | }) 33 | _ = bld.AddNode(&node.Node{ 34 | ID: "node-2", 35 | Name: "2", 36 | Image: "node-image", 37 | Cluster: "c1", 38 | Ports: makeTestPorts([]*node.Port{ 39 | {Kind: "tcp", Value: "1"}, 40 | {Kind: "tcp", Value: "2"}, 41 | }...), 42 | Networks: []string{"test-net"}, 43 | Meta: &node.Meta{ 44 | Info: "info 2", 45 | Tags: []string{"2"}, 46 | }, 47 | }) 48 | _ = bld.AddNode(&node.Node{ 49 | ID: "node-3", 50 | Name: "3", 51 | Image: "node-image", 52 | Cluster: "c3", 53 | Ports: makeTestPorts([]*node.Port{ 54 | {Kind: "tcp", Value: "1"}, 55 | {Kind: "tcp", Value: "2"}, 56 | }...), 57 | Networks: []string{"test-net"}, 58 | Meta: &node.Meta{ 59 | Info: "info 3", 60 | Tags: []string{"3"}, 61 | }, 62 | }) 63 | _ = bld.AddNode(&node.Node{ 64 | ID: "2", 65 | Name: "2", 66 | Cluster: "c2", 67 | Ports: makeTestPorts([]*node.Port{ 68 | {Kind: "tcp", Value: "2"}, 69 | }...), 70 | }) 71 | 72 | bld.AddEdge(&node.Edge{ 73 | SrcID: "2", 74 | DstID: "node-1", 75 | Port: &node.Port{Kind: "tcp", Value: "1"}, 76 | }) 77 | 78 | bld.AddEdge(&node.Edge{ 79 | SrcID: "2", 80 | DstID: "node-1", 81 | Port: &node.Port{Kind: "tcp", Value: "2"}, 82 | }) 83 | 84 | bld.AddEdge(&node.Edge{ 85 | SrcID: "2", 86 | DstID: "node-1", 87 | Port: &node.Port{Kind: "tcp", Value: "3"}, 88 | }) 89 | 90 | bld.AddEdge(&node.Edge{ 91 | SrcID: "node-1", 92 | DstID: "2", 93 | Port: &node.Port{Kind: "tcp", Value: "2"}, 94 | }) 95 | 96 | bld.AddEdge(&node.Edge{ 97 | SrcID: "node-1", 98 | DstID: "2", 99 | Port: &node.Port{Kind: "tcp", Value: "3"}, 100 | }) 101 | 102 | bld.AddEdge(&node.Edge{ 103 | SrcID: "node-1", 104 | DstID: "3", 105 | Port: &node.Port{Kind: "tcp", Value: "3"}, 106 | }) 107 | 108 | bld.AddEdge(&node.Edge{ 109 | SrcID: "3", 110 | DstID: "node-1", 111 | Port: &node.Port{Kind: "tcp", Value: "3"}, 112 | }) 113 | 114 | bld.AddEdge(&node.Edge{ 115 | SrcID: "c1", 116 | DstID: "c2", 117 | Port: &node.Port{Kind: "tcp", Value: "2"}, 118 | }) 119 | 120 | bld.AddEdge(&node.Edge{ 121 | SrcID: "c1", 122 | DstID: "c2", 123 | Port: &node.Port{Kind: "tcp", Value: "2"}, 124 | }) 125 | 126 | bld.AddEdge(&node.Edge{ 127 | SrcID: "c2", 128 | DstID: "c1", 129 | Port: &node.Port{Kind: "tcp", Value: "1"}, 130 | }) 131 | 132 | bld.AddEdge(&node.Edge{ 133 | SrcID: "c2", 134 | DstID: "c1", 135 | Port: &node.Port{Kind: "tcp", Value: "1"}, 136 | }) 137 | 138 | bld.AddEdge(&node.Edge{ 139 | SrcID: "c1", 140 | DstID: "", 141 | Port: &node.Port{}, 142 | }) 143 | 144 | bld.AddEdge(&node.Edge{ 145 | SrcID: "", 146 | DstID: "c2", 147 | Port: &node.Port{}, 148 | }) 149 | 150 | bld.AddEdge(&node.Edge{ 151 | SrcID: "node-1", 152 | DstID: "node-2", 153 | Port: &node.Port{Kind: "tcp", Value: "1"}, 154 | }) 155 | 156 | bld.AddEdge(&node.Edge{ 157 | SrcID: "node-1", 158 | DstID: "node-2", 159 | Port: &node.Port{Kind: "tcp", Value: "1"}, 160 | }) 161 | 162 | bld.AddEdge(&node.Edge{ 163 | SrcID: "node-2", 164 | DstID: "node-1", 165 | Port: &node.Port{Kind: "tcp", Value: "2"}, 166 | }) 167 | 168 | bld.AddEdge(&node.Edge{ 169 | SrcID: "node-2", 170 | DstID: "node-1", 171 | Port: &node.Port{Kind: "tcp", Value: "2"}, 172 | }) 173 | 174 | bld.AddEdge(&node.Edge{ 175 | SrcID: "q", 176 | DstID: "node-1", 177 | Port: &node.Port{Kind: "tcp", Value: "2"}, 178 | }) 179 | 180 | bld.AddEdge(&node.Edge{ 181 | SrcID: "node-2", 182 | DstID: "1", 183 | Port: &node.Port{Kind: "tcp", Value: "2"}, 184 | }) 185 | 186 | var buf bytes.Buffer 187 | 188 | bld.Write(&buf) 189 | 190 | got := buf.String() 191 | want := golden(t, bld.Name(), got) 192 | 193 | if got != want { 194 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /internal/builder/json.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "slices" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | type JSON struct { 14 | state map[string]*node.JSON 15 | } 16 | 17 | func NewJSON() *JSON { 18 | return &JSON{ 19 | state: make(map[string]*node.JSON), 20 | } 21 | } 22 | 23 | func (j *JSON) Name() string { 24 | return "json-stream" 25 | } 26 | 27 | func (j *JSON) AddNode(n *node.Node) error { 28 | j.state[n.ID] = n.ToJSON() 29 | 30 | return nil 31 | } 32 | 33 | func (j *JSON) AddEdge(e *node.Edge) { 34 | src, ok := j.state[e.SrcID] 35 | if !ok { 36 | return 37 | } 38 | 39 | dst, ok := j.state[e.DstID] 40 | if !ok { 41 | return 42 | } 43 | 44 | con, ok := src.Connected[dst.Name] 45 | if !ok { 46 | con = make([]*node.Connection, 0, 1) 47 | } 48 | 49 | src.Connected[dst.Name] = append(con, &node.Connection{ 50 | Src: e.SrcName, 51 | Dst: e.DstName, 52 | Port: e.Port, 53 | }) 54 | } 55 | 56 | func (j *JSON) Sorted(fn func(*node.JSON, bool)) { 57 | nodes := make([]*node.JSON, 0, len(j.state)) 58 | 59 | for _, n := range j.state { 60 | nodes = append(nodes, n) 61 | } 62 | 63 | slices.SortFunc(nodes, func(a, b *node.JSON) int { 64 | return cmp.Compare(a.Name, b.Name) 65 | }) 66 | 67 | for i, n := range nodes { 68 | fn(n, i == len(nodes)-1) 69 | } 70 | } 71 | 72 | func (j *JSON) Write(w io.Writer) (err error) { 73 | jw := json.NewEncoder(w) 74 | jw.SetIndent("", " ") 75 | 76 | j.Sorted(func(n *node.JSON, _ bool) { 77 | err = jw.Encode(n) 78 | }) 79 | 80 | if err != nil { 81 | return fmt.Errorf("encode: %w", err) 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/builder/json_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/s0rg/decompose/internal/builder" 11 | "github.com/s0rg/decompose/internal/graph" 12 | "github.com/s0rg/decompose/internal/node" 13 | ) 14 | 15 | type testEnricher struct{} 16 | 17 | func (de *testEnricher) Enrich(_ *node.Node) {} 18 | 19 | type errWriter struct { 20 | Err error 21 | } 22 | 23 | func (ew *errWriter) Write(_ []byte) (n int, err error) { return 0, ew.Err } 24 | 25 | func TestJSON(t *testing.T) { 26 | t.Parallel() 27 | 28 | testNode := node.JSON{ 29 | Name: "test1", 30 | Networks: []string{"test"}, 31 | Listen: map[string][]*node.Port{ 32 | "foo": { 33 | &node.Port{Kind: "tcp", Value: "2"}, 34 | &node.Port{Kind: "udp", Value: "1"}, 35 | }, 36 | }, 37 | Tags: []string{}, 38 | Connected: make(map[string][]*node.Connection), 39 | Volumes: []*node.Volume{}, 40 | } 41 | 42 | jnode, err := json.Marshal(testNode) 43 | if err != nil { 44 | t.Fatal("marshal err=", err) 45 | } 46 | 47 | var rawCompact bytes.Buffer 48 | 49 | if err := json.Compact(&rawCompact, jnode); err != nil { 50 | t.Fatal("raw compact err=", err) 51 | } 52 | 53 | bldr := builder.NewJSON() 54 | 55 | if bldr.Name() != "json-stream" { 56 | t.Fail() 57 | } 58 | 59 | ext := &testEnricher{} 60 | 61 | cfg := &graph.Config{ 62 | Builder: bldr, 63 | Meta: ext, 64 | Proto: graph.ALL, 65 | } 66 | 67 | ldr := graph.NewLoader(cfg) 68 | 69 | if err := ldr.FromReader(bytes.NewBuffer(jnode)); err != nil { 70 | t.Fatal("load err=", err) 71 | } 72 | 73 | if err := ldr.Build(); err != nil { 74 | t.Fatal("build err=", err) 75 | } 76 | 77 | var buf bytes.Buffer 78 | 79 | bldr.Write(&buf) 80 | 81 | var resCompact bytes.Buffer 82 | 83 | if err := json.Compact(&resCompact, buf.Bytes()); err != nil { 84 | t.Fatal("res compact err=", err) 85 | } 86 | 87 | if rawCompact.String() != resCompact.String() { 88 | t.Log("want:", rawCompact.String()) 89 | t.Log("got:", resCompact.String()) 90 | 91 | t.Fail() 92 | } 93 | } 94 | 95 | func TestJSONAddEdge(t *testing.T) { 96 | t.Parallel() 97 | 98 | const raw = `{ 99 | "name": "test1", 100 | "is_external": false, 101 | "networks": ["test"], 102 | "listen": {"foo":[ 103 | {"kind": "udp", "value": "1"} 104 | ]}, 105 | "connected": {"test2":[{"src": "foo", "dst": "bar", "port": {"kind": "tcp", "value": "2"}}]} 106 | } 107 | { 108 | "name": "test2", 109 | "is_external": false, 110 | "networks": ["test"], 111 | "listen": {"bar": [ 112 | {"kind": "tcp", "value": "2"} 113 | ]}, 114 | "connected": {"test1":[{"src": "bar", "dst": "foo", "port": {"kind": "udp", "value": "1"}}]} 115 | }` 116 | 117 | bldr := builder.NewJSON() 118 | ext := &testEnricher{} 119 | 120 | cfg := &graph.Config{ 121 | Builder: bldr, 122 | Meta: ext, 123 | Proto: graph.ALL, 124 | } 125 | 126 | ldr := graph.NewLoader(cfg) 127 | 128 | if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { 129 | t.Fatal("load err=", err) 130 | } 131 | 132 | if err := ldr.Build(); err != nil { 133 | t.Fatal("build err=", err) 134 | } 135 | 136 | var buf bytes.Buffer 137 | 138 | bldr.Write(&buf) 139 | 140 | if strings.Count(buf.String(), "test2") < 2 { 141 | t.Log(buf) 142 | t.Fail() 143 | } 144 | } 145 | 146 | type testBuilder struct { 147 | Nodes int 148 | Edges int 149 | } 150 | 151 | func (tb *testBuilder) AddNode(_ *node.Node) error { 152 | tb.Nodes++ 153 | 154 | return nil 155 | } 156 | 157 | func (tb *testBuilder) AddEdge(_ *node.Edge) { 158 | tb.Edges++ 159 | } 160 | 161 | func TestJSONAddBadEdges(t *testing.T) { 162 | t.Parallel() 163 | 164 | bld := builder.NewJSON() 165 | 166 | _ = bld.AddNode(&node.Node{ID: "1", Name: "1", Ports: &node.Ports{}}) 167 | _ = bld.AddNode(&node.Node{ID: "2", Name: "2", Ports: &node.Ports{}}) 168 | 169 | bld.AddEdge(&node.Edge{SrcID: "3", DstID: "1", Port: &node.Port{}}) 170 | bld.AddEdge(&node.Edge{SrcID: "1", DstID: "3", Port: &node.Port{}}) 171 | 172 | var buf bytes.Buffer 173 | 174 | bld.Write(&buf) 175 | 176 | tb := &testBuilder{} 177 | ext := &testEnricher{} 178 | 179 | cfg := &graph.Config{ 180 | Builder: tb, 181 | Meta: ext, 182 | Proto: graph.ALL, 183 | } 184 | 185 | ldr := graph.NewLoader(cfg) 186 | 187 | if err := ldr.FromReader(&buf); err != nil { 188 | t.Fatal("load err=", err) 189 | } 190 | 191 | if err := ldr.Build(); err != nil { 192 | t.Fatal("build err=", err) 193 | } 194 | 195 | if tb.Nodes != 2 || tb.Edges != 0 { 196 | t.Fail() 197 | } 198 | } 199 | 200 | func TestJSONWriteError(t *testing.T) { 201 | t.Parallel() 202 | 203 | bldr := builder.NewJSON() 204 | testErr := errors.New("test-error") 205 | errW := &errWriter{Err: testErr} 206 | 207 | _ = bldr.AddNode(&node.Node{ID: "1", Name: "1", Ports: &node.Ports{}}) 208 | 209 | if err := bldr.Write(errW); !errors.Is(err, testErr) { 210 | t.Fail() 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /internal/builder/main_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | const ( 14 | goldenRoot = "testdata" 15 | goldenExt = ".golden" 16 | ) 17 | 18 | var ( 19 | update = flag.Bool("update", false, "update the golden files of this test") 20 | ) 21 | 22 | func TestMain(m *testing.M) { 23 | flag.Parse() 24 | os.MkdirAll(goldenRoot, 0o664) 25 | os.Exit(m.Run()) 26 | } 27 | 28 | func golden(t *testing.T, file, actual string) string { 29 | t.Helper() 30 | 31 | path := filepath.Join(goldenRoot, file+goldenExt) 32 | 33 | fd, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) 34 | if err != nil { 35 | t.Fatalf("Error opening file %s: %s", path, err) 36 | } 37 | 38 | defer fd.Close() 39 | 40 | if *update { 41 | if _, werr := fd.WriteString(actual); err != nil { 42 | t.Fatalf("Error writing to file %s: %s", path, werr) 43 | } 44 | 45 | return actual 46 | } 47 | 48 | content, err := io.ReadAll(fd) 49 | if err != nil { 50 | t.Fatalf("Error reading file %s: %s", path, err) 51 | } 52 | 53 | return string(content) 54 | } 55 | 56 | func makeTestPorts(vals ...*node.Port) (rv *node.Ports) { 57 | rv = &node.Ports{} 58 | 59 | for _, p := range vals { 60 | rv.Add("", p) 61 | } 62 | 63 | return rv 64 | } 65 | -------------------------------------------------------------------------------- /internal/builder/puml.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/hex" 6 | "fmt" 7 | "hash/fnv" 8 | "io" 9 | "slices" 10 | 11 | "github.com/s0rg/decompose/internal/node" 12 | ) 13 | 14 | type PlantUML struct { 15 | nodes map[string]*node.Node 16 | conns map[string]map[string][]*node.Port 17 | order []string 18 | } 19 | 20 | func NewPlantUML() *PlantUML { 21 | return &PlantUML{ 22 | nodes: make(map[string]*node.Node), 23 | conns: make(map[string]map[string][]*node.Port), 24 | } 25 | } 26 | 27 | func (p *PlantUML) Name() string { 28 | return "plant-uml" 29 | } 30 | 31 | func (p *PlantUML) AddNode(n *node.Node) error { 32 | p.nodes[n.ID] = n 33 | p.order = append(p.order, n.ID) 34 | 35 | return nil 36 | } 37 | 38 | func (p *PlantUML) AddEdge(e *node.Edge) { 39 | nsrc, ok := p.nodes[e.SrcID] 40 | if !ok { 41 | return 42 | } 43 | 44 | ndst, ok := p.nodes[e.DstID] 45 | if !ok { 46 | return 47 | } 48 | 49 | if !e.Port.Local && nsrc.Cluster != ndst.Cluster { 50 | e.SrcID, e.DstID = nsrc.Cluster, ndst.Cluster 51 | } 52 | 53 | mdst, ok := p.conns[e.SrcID] 54 | if !ok { 55 | mdst = make(map[string][]*node.Port) 56 | p.conns[e.SrcID] = mdst 57 | } 58 | 59 | ports, ok := mdst[e.DstID] 60 | if !ok { 61 | ports = make([]*node.Port, 0, 1) 62 | } 63 | 64 | mdst[e.DstID] = append(ports, e.Port) 65 | } 66 | 67 | func (p *PlantUML) Write(w io.Writer) error { 68 | fmt.Fprintln(w, "@startuml") 69 | fmt.Fprintln(w, "skinparam componentStyle rectangle") 70 | fmt.Fprintln(w, "skinparam nodesep 5") 71 | fmt.Fprintln(w, "skinparam ranksep 5") 72 | 73 | p.writeNodes(w) 74 | 75 | fmt.Fprintln(w, "") 76 | 77 | p.writeEdges(w) 78 | 79 | fmt.Fprintln(w, "@enduml") 80 | 81 | return nil 82 | } 83 | 84 | func (p *PlantUML) writeNodes(w io.Writer) { 85 | slices.Sort(p.order) 86 | 87 | cloud := []*node.Node{} 88 | 89 | for _, id := range p.order { 90 | nod := p.nodes[id] 91 | 92 | if nod.IsExternal() { 93 | cloud = append(cloud, nod) 94 | 95 | continue 96 | } 97 | 98 | np := []*node.Port{} 99 | 100 | fmt.Fprintf(w, "component \"%s\" as %s {\n", nod.Name, 101 | makeID(nod.Cluster, nod.Name), 102 | ) 103 | 104 | nod.Ports.Iter(func(process string, ports []*node.Port) { 105 | fmt.Fprintf(w, " component \"%s\" as %s {\n", 106 | process, 107 | makeID(nod.Cluster, nod.Name, process), 108 | ) 109 | 110 | for _, prt := range ports { 111 | fmt.Fprintf(w, " portin \"%s\" as %s\n", 112 | prt.Label(), 113 | makeID(nod.Cluster, nod.Name, process, prt.Label()), 114 | ) 115 | 116 | if !prt.Local { 117 | np = append(np, prt) 118 | } 119 | } 120 | 121 | fmt.Fprintln(w, " }") 122 | }) 123 | 124 | for _, prt := range np { 125 | fmt.Fprintf(w, " portin \"%s\" as %s\n", 126 | prt.Label(), 127 | makeID(nod.Cluster, nod.Name, prt.Label()), 128 | ) 129 | } 130 | 131 | fmt.Fprintln(w, "}") 132 | } 133 | 134 | if len(cloud) > 0 { 135 | fmt.Fprintln(w, "cloud \"Externals\" as ext {") 136 | 137 | for _, nod := range cloud { 138 | fmt.Fprintf(w, " component \"%s\" as %s {\n", nod.Name, makeID("ext", nod.Name)) 139 | 140 | nod.Ports.Iter(func(_ string, ports []*node.Port) { 141 | for _, prt := range ports { 142 | fmt.Fprintf(w, " portin \"%s\" as %s\n", 143 | prt.Label(), 144 | makeID("ext", nod.Name, prt.Label()), 145 | ) 146 | } 147 | 148 | fmt.Fprintln(w, " }") 149 | }) 150 | 151 | fmt.Fprintln(w, " }") 152 | } 153 | } 154 | 155 | fmt.Fprintln(w, "}") 156 | } 157 | 158 | func (p *PlantUML) writeEdges(w io.Writer) { 159 | order := make([]string, 0, len(p.conns)) 160 | 161 | for k := range p.conns { 162 | order = append(order, k) 163 | } 164 | 165 | slices.Sort(order) 166 | 167 | for _, src := range order { 168 | dmap := p.conns[src] 169 | 170 | nsrc, ok := p.nodes[src] 171 | if !ok { 172 | continue 173 | } 174 | 175 | locals := make(map[string]string) 176 | 177 | nsrc.Ports.Iter(func(process string, ports []*node.Port) { 178 | for _, prt := range ports { 179 | locals[prt.Label()] = process 180 | } 181 | }) 182 | 183 | nsrc.Ports.Iter(func(process string, ports []*node.Port) { 184 | for _, prt := range ports { 185 | if !prt.Local { 186 | fmt.Fprintf(w, "%s -> %s\n", 187 | makeID(nsrc.Cluster, nsrc.Name, prt.Label()), 188 | makeID(nsrc.Cluster, nsrc.Name, process, prt.Label()), 189 | ) 190 | } 191 | } 192 | }) 193 | 194 | dorder := make([]string, 0, len(dmap)) 195 | 196 | for k := range dmap { 197 | dorder = append(dorder, k) 198 | } 199 | 200 | slices.Sort(dorder) 201 | 202 | for _, dst := range dorder { 203 | ports := dmap[dst] 204 | ndst := p.nodes[dst] 205 | 206 | for _, prt := range ports { 207 | if prt.Local { 208 | dstp := locals[prt.Label()] 209 | 210 | fmt.Fprintf(w, "%s --> %s\n", 211 | makeID(nsrc.Cluster, nsrc.Name), 212 | makeID(nsrc.Cluster, nsrc.Name, dstp, prt.Label()), 213 | ) 214 | } else { 215 | fmt.Fprintf(w, "%s -----> %s: %s\n", 216 | makeID(nsrc.Cluster, nsrc.Name), 217 | makeID(ndst.Cluster, ndst.Name, prt.Label()), 218 | prt.Label(), 219 | ) 220 | } 221 | } 222 | } 223 | } 224 | } 225 | 226 | func makeID(parts ...string) (rv string) { 227 | h := fnv.New64a() 228 | 229 | for _, p := range parts { 230 | _, _ = io.WriteString(h, p) 231 | } 232 | 233 | b := make([]byte, binary.MaxVarintLen64) 234 | n := binary.PutUvarint(b, h.Sum64()) 235 | 236 | return "id_" + hex.EncodeToString(b[:n]) 237 | } 238 | -------------------------------------------------------------------------------- /internal/builder/puml_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/s0rg/decompose/internal/builder" 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | func TestPumlGolden(t *testing.T) { 12 | t.Parallel() 13 | 14 | bld := builder.NewPlantUML() 15 | 16 | _ = bld.AddNode(&node.Node{ 17 | ID: "node-1", 18 | Name: "1", 19 | Image: "node-image", 20 | Cluster: "c1", 21 | Ports: makeTestPorts([]*node.Port{ 22 | {Kind: "tcp", Value: "1"}, 23 | {Kind: "tcp", Value: "2"}, 24 | {Kind: "tcp", Value: "5", Local: true}, 25 | }...), 26 | Networks: []string{"test-net"}, 27 | Meta: &node.Meta{ 28 | Info: "info 1", 29 | Docs: "docs-url", 30 | Repo: "repo-url", 31 | Tags: []string{"1"}, 32 | }, 33 | }) 34 | _ = bld.AddNode(&node.Node{ 35 | ID: "node-2", 36 | Name: "2", 37 | Image: "node-image", 38 | Cluster: "c1", 39 | Ports: makeTestPorts([]*node.Port{ 40 | {Kind: "tcp", Value: "1"}, 41 | {Kind: "tcp", Value: "2"}, 42 | }...), 43 | Networks: []string{"test-net"}, 44 | Meta: &node.Meta{ 45 | Info: "info 2", 46 | Tags: []string{"2"}, 47 | }, 48 | }) 49 | _ = bld.AddNode(&node.Node{ 50 | ID: "node-3", 51 | Name: "3", 52 | Image: "node-image", 53 | Ports: makeTestPorts([]*node.Port{ 54 | {Kind: "tcp", Value: "1"}, 55 | {Kind: "tcp", Value: "2"}, 56 | }...), 57 | Networks: []string{"test-net"}, 58 | Meta: &node.Meta{ 59 | Info: "info 3", 60 | Tags: []string{"3"}, 61 | }, 62 | }) 63 | 64 | _ = bld.AddNode(&node.Node{ 65 | ID: "ext2", 66 | Name: "ext2", 67 | Cluster: "c2", 68 | Ports: makeTestPorts([]*node.Port{ 69 | {Kind: "tcp", Value: "2"}, 70 | }...), 71 | }) 72 | 73 | _ = bld.AddNode(&node.Node{ 74 | ID: "ext2", 75 | Name: "ext2", 76 | Cluster: "c2", 77 | Ports: makeTestPorts([]*node.Port{ 78 | {Kind: "tcp", Value: "443"}, 79 | }...), 80 | }) 81 | 82 | bld.AddEdge(&node.Edge{ 83 | SrcID: "ext2", 84 | DstID: "node-1", 85 | Port: &node.Port{Kind: "tcp", Value: "1"}, 86 | }) 87 | 88 | bld.AddEdge(&node.Edge{ 89 | SrcID: "node-1", 90 | DstID: "node-1", 91 | Port: &node.Port{Kind: "tcp", Value: "5", Local: true}, 92 | }) 93 | 94 | bld.AddEdge(&node.Edge{ 95 | SrcID: "ext2", 96 | DstID: "node-1", 97 | Port: &node.Port{Kind: "tcp", Value: "2"}, 98 | }) 99 | 100 | bld.AddEdge(&node.Edge{ 101 | SrcID: "ext2", 102 | DstID: "node-1", 103 | Port: &node.Port{Kind: "tcp", Value: "3"}, 104 | }) 105 | 106 | bld.AddEdge(&node.Edge{ 107 | SrcID: "node-1", 108 | DstID: "ext2", 109 | Port: &node.Port{Kind: "tcp", Value: "2"}, 110 | }) 111 | 112 | bld.AddEdge(&node.Edge{ 113 | SrcID: "node-1", 114 | DstID: "ext2", 115 | Port: &node.Port{Kind: "tcp", Value: "3"}, 116 | }) 117 | 118 | bld.AddEdge(&node.Edge{ 119 | SrcID: "node-1", 120 | DstID: "3", 121 | Port: &node.Port{}, 122 | }) 123 | 124 | bld.AddEdge(&node.Edge{ 125 | SrcID: "3", 126 | DstID: "node-1", 127 | Port: &node.Port{}, 128 | }) 129 | 130 | bld.AddEdge(&node.Edge{ 131 | SrcID: "node-2", 132 | DstID: "node-1", 133 | Port: &node.Port{Kind: "tcp", Value: "1"}, 134 | }) 135 | 136 | bld.AddEdge(&node.Edge{ 137 | SrcID: "node-2", 138 | DstID: "node-1", 139 | Port: &node.Port{Kind: "tcp", Value: "2"}, 140 | }) 141 | 142 | bld.AddEdge(&node.Edge{ 143 | SrcID: "node-2", 144 | DstID: "node-1", 145 | Port: &node.Port{Kind: "tcp", Value: "3"}, 146 | }) 147 | 148 | bld.AddEdge(&node.Edge{ 149 | SrcID: "node-2", 150 | DstID: "node-3", 151 | Port: &node.Port{Kind: "tcp", Value: "3"}, 152 | }) 153 | 154 | bld.AddEdge(&node.Edge{ 155 | SrcID: "node-1", 156 | DstID: "node-3", 157 | Port: &node.Port{Kind: "tcp", Value: "3"}, 158 | }) 159 | 160 | bld.AddEdge(&node.Edge{ 161 | SrcID: "node-1", 162 | DstID: "node-2", 163 | Port: &node.Port{Kind: "tcp", Value: "2"}, 164 | }) 165 | 166 | bld.AddEdge(&node.Edge{ 167 | SrcID: "node-1", 168 | DstID: "c2", 169 | Port: &node.Port{Kind: "tcp", Value: "2"}, 170 | }) 171 | 172 | bld.AddEdge(&node.Edge{ 173 | SrcID: "node-3", 174 | DstID: "node-1", 175 | Port: &node.Port{Kind: "tcp", Value: "1"}, 176 | }) 177 | 178 | bld.AddEdge(&node.Edge{ 179 | SrcID: "node-3", 180 | DstID: "node-1", 181 | Port: &node.Port{Kind: "tcp", Value: "2"}, 182 | }) 183 | 184 | bld.AddEdge(&node.Edge{ 185 | SrcID: "c1", 186 | DstID: "2", 187 | Port: &node.Port{Kind: "tcp", Value: "2"}, 188 | }) 189 | 190 | bld.AddEdge(&node.Edge{ 191 | SrcID: "c1", 192 | DstID: "c2", 193 | Port: &node.Port{}, 194 | }) 195 | 196 | bld.AddEdge(&node.Edge{ 197 | SrcID: "c1", 198 | DstID: "", 199 | Port: &node.Port{}, 200 | }) 201 | 202 | bld.AddEdge(&node.Edge{ 203 | SrcID: "", 204 | DstID: "c2", 205 | Port: &node.Port{}, 206 | }) 207 | 208 | bld.AddEdge(&node.Edge{ 209 | SrcID: "node-1", 210 | DstID: "node-4", 211 | Port: &node.Port{}, 212 | }) 213 | 214 | bld.AddEdge(&node.Edge{ 215 | SrcID: "node-3", 216 | DstID: "node-1", 217 | Port: &node.Port{}, 218 | }) 219 | 220 | var buf bytes.Buffer 221 | 222 | bld.Write(&buf) 223 | 224 | got := buf.String() 225 | want := golden(t, bld.Name(), got) 226 | 227 | if got != want { 228 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /internal/builder/sdsl.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/s0rg/decompose/internal/node" 10 | sdsl "github.com/s0rg/decompose/internal/structurizr" 11 | ) 12 | 13 | const ( 14 | workspaceName = "de-composed system" 15 | systemName = "default" 16 | ) 17 | 18 | var ErrDuplicate = errors.New("duplicate found") 19 | 20 | type Structurizr struct { 21 | ws *sdsl.Workspace 22 | } 23 | 24 | func NewStructurizr() *Structurizr { 25 | return &Structurizr{ 26 | ws: sdsl.NewWorkspace(workspaceName, systemName), 27 | } 28 | } 29 | 30 | func (s *Structurizr) Name() string { 31 | return "structurizr-dsl" 32 | } 33 | 34 | func (s *Structurizr) AddNode(n *node.Node) error { 35 | system := systemName 36 | if n.Cluster != "" { 37 | system = n.Cluster 38 | } 39 | 40 | state := s.ws.System(system) 41 | 42 | state.Tags = append(state.Tags, n.Name) 43 | 44 | cont, ok := state.AddContainer(n.ID, n.Name) 45 | if !ok { 46 | return fmt.Errorf("%w: %s", ErrDuplicate, n.Name) 47 | } 48 | 49 | cont.Technology = n.Image 50 | 51 | n.Ports.Iter(func(process string, plist []*node.Port) { 52 | com := &sdsl.Component{ 53 | ID: sdsl.SafeID(n.ID + "_" + process), 54 | Name: process, 55 | } 56 | 57 | for _, p := range plist { 58 | tag := "listen:" + p.Label() 59 | 60 | com.Tags = append(com.Tags, tag) 61 | 62 | if !p.Local { 63 | cont.Tags = append(cont.Tags, tag) 64 | } 65 | } 66 | 67 | cont.Components = append(cont.Components, com) 68 | }) 69 | 70 | for _, n := range n.Networks { 71 | cont.Tags = append(cont.Tags, "net:"+n) 72 | } 73 | 74 | if n.IsExternal() { 75 | cont.Tags = append(cont.Tags, "external") 76 | } 77 | 78 | if n.Meta != nil { 79 | if lines, ok := n.FormatMeta(); ok { 80 | cont.Description = strings.Join(lines, " \\\n") 81 | } 82 | 83 | if len(n.Meta.Tags) > 0 { 84 | cont.Tags = append(cont.Tags, n.Meta.Tags...) 85 | } 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (s *Structurizr) AddEdge(e *node.Edge) { 92 | var ( 93 | rel *sdsl.Relation 94 | ok bool 95 | ) 96 | 97 | switch { 98 | case e.SrcID == "": 99 | e.SrcID = systemName 100 | case e.DstID == "": 101 | e.DstID = systemName 102 | } 103 | 104 | if s.ws.HasSystem(e.SrcID) { 105 | rel, ok = s.ws.AddRelation(e.SrcID, e.DstID, e.SrcID, e.DstID) 106 | } else { 107 | rel, ok = s.ws.System(systemName).AddRelation(e.SrcID, e.DstID, e.SrcName, e.DstName) 108 | } 109 | 110 | if !ok { 111 | return 112 | } 113 | 114 | rel.Tags = append(rel.Tags, e.Port.Label()) 115 | } 116 | 117 | func (s *Structurizr) Write(w io.Writer) error { 118 | s.ws.Write(w) 119 | 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /internal/builder/sdsl_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/s0rg/decompose/internal/builder" 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | func TestSDSLGolden(t *testing.T) { 12 | t.Parallel() 13 | 14 | bld := builder.NewStructurizr() 15 | 16 | _ = bld.AddNode(&node.Node{ 17 | ID: "node-1", 18 | Name: "1", 19 | Image: "node-image", 20 | Cluster: "c1", 21 | Ports: makeTestPorts([]*node.Port{ 22 | {Kind: "tcp", Value: "1"}, 23 | {Kind: "tcp", Value: "2"}, 24 | }...), 25 | Networks: []string{"test-net"}, 26 | Meta: &node.Meta{ 27 | Info: "info 1", 28 | Docs: "docs-url", 29 | Repo: "repo-url", 30 | Tags: []string{"1"}, 31 | }, 32 | }) 33 | _ = bld.AddNode(&node.Node{ 34 | ID: "node-2", 35 | Name: "2", 36 | Image: "node-image", 37 | Cluster: "c1", 38 | Ports: makeTestPorts([]*node.Port{ 39 | {Kind: "tcp", Value: "1"}, 40 | {Kind: "tcp", Value: "2"}, 41 | }...), 42 | Networks: []string{"test-net"}, 43 | Meta: &node.Meta{ 44 | Info: "info 2", 45 | Tags: []string{"2"}, 46 | }, 47 | }) 48 | _ = bld.AddNode(&node.Node{ 49 | ID: "node-3", 50 | Name: "3", 51 | Image: "node-image", 52 | Ports: makeTestPorts([]*node.Port{ 53 | {Kind: "tcp", Value: "1"}, 54 | {Kind: "tcp", Value: "2"}, 55 | }...), 56 | Networks: []string{"test-net"}, 57 | Meta: &node.Meta{ 58 | Info: "info 3", 59 | Tags: []string{"3"}, 60 | }, 61 | }) 62 | 63 | _ = bld.AddNode(&node.Node{ 64 | ID: "ext2", 65 | Name: "ext2", 66 | Cluster: "c2", 67 | Ports: makeTestPorts([]*node.Port{ 68 | {Kind: "tcp", Value: "2"}, 69 | }...), 70 | }) 71 | 72 | _ = bld.AddNode(&node.Node{ID: "ext2", Name: "ext2", Cluster: "c2"}) 73 | 74 | bld.AddEdge(&node.Edge{ 75 | SrcID: "ext2", 76 | DstID: "node-1", 77 | Port: &node.Port{Kind: "tcp", Value: "1"}, 78 | }) 79 | 80 | bld.AddEdge(&node.Edge{ 81 | SrcID: "ext2", 82 | DstID: "node-1", 83 | Port: &node.Port{Kind: "tcp", Value: "2"}, 84 | }) 85 | 86 | bld.AddEdge(&node.Edge{ 87 | SrcID: "ext2", 88 | DstID: "node-1", 89 | Port: &node.Port{Kind: "tcp", Value: "3"}, 90 | }) 91 | 92 | bld.AddEdge(&node.Edge{ 93 | SrcID: "node-1", 94 | DstID: "ext2", 95 | Port: &node.Port{Kind: "tcp", Value: "2"}, 96 | }) 97 | 98 | bld.AddEdge(&node.Edge{ 99 | SrcID: "node-1", 100 | DstID: "ext2", 101 | Port: &node.Port{Kind: "tcp", Value: "3"}, 102 | }) 103 | 104 | bld.AddEdge(&node.Edge{ 105 | SrcID: "node-1", 106 | DstID: "3", 107 | Port: &node.Port{}, 108 | }) 109 | 110 | bld.AddEdge(&node.Edge{ 111 | SrcID: "3", 112 | DstID: "node-1", 113 | Port: &node.Port{}, 114 | }) 115 | 116 | bld.AddEdge(&node.Edge{ 117 | SrcID: "node-2", 118 | DstID: "node-1", 119 | Port: &node.Port{Kind: "tcp", Value: "1"}, 120 | }) 121 | 122 | bld.AddEdge(&node.Edge{ 123 | SrcID: "node-2", 124 | DstID: "node-1", 125 | Port: &node.Port{Kind: "tcp", Value: "2"}, 126 | }) 127 | 128 | bld.AddEdge(&node.Edge{ 129 | SrcID: "node-2", 130 | DstID: "node-1", 131 | Port: &node.Port{Kind: "tcp", Value: "3"}, 132 | }) 133 | 134 | bld.AddEdge(&node.Edge{ 135 | SrcID: "node-2", 136 | DstID: "node-3", 137 | Port: &node.Port{Kind: "tcp", Value: "3"}, 138 | }) 139 | 140 | bld.AddEdge(&node.Edge{ 141 | SrcID: "node-1", 142 | DstID: "node-3", 143 | Port: &node.Port{Kind: "tcp", Value: "3"}, 144 | }) 145 | 146 | bld.AddEdge(&node.Edge{ 147 | SrcID: "node-1", 148 | DstID: "node-2", 149 | Port: &node.Port{Kind: "tcp", Value: "2"}, 150 | }) 151 | 152 | bld.AddEdge(&node.Edge{ 153 | SrcID: "node-1", 154 | DstID: "c2", 155 | Port: &node.Port{Kind: "tcp", Value: "2"}, 156 | }) 157 | 158 | bld.AddEdge(&node.Edge{ 159 | SrcID: "node-3", 160 | DstID: "node-1", 161 | Port: &node.Port{Kind: "tcp", Value: "1"}, 162 | }) 163 | 164 | bld.AddEdge(&node.Edge{ 165 | SrcID: "node-3", 166 | DstID: "node-1", 167 | Port: &node.Port{Kind: "tcp", Value: "2"}, 168 | }) 169 | 170 | bld.AddEdge(&node.Edge{ 171 | SrcID: "c1", 172 | DstID: "2", 173 | Port: &node.Port{Kind: "tcp", Value: "2"}, 174 | }) 175 | 176 | bld.AddEdge(&node.Edge{ 177 | SrcID: "c1", 178 | DstID: "c2", 179 | Port: &node.Port{}, 180 | }) 181 | 182 | bld.AddEdge(&node.Edge{ 183 | SrcID: "c1", 184 | DstID: "", 185 | Port: &node.Port{}, 186 | }) 187 | 188 | bld.AddEdge(&node.Edge{ 189 | SrcID: "", 190 | DstID: "c2", 191 | Port: &node.Port{}, 192 | }) 193 | 194 | bld.AddEdge(&node.Edge{ 195 | SrcID: "node-1", 196 | DstID: "node-4", 197 | Port: &node.Port{}, 198 | }) 199 | 200 | bld.AddEdge(&node.Edge{ 201 | SrcID: "node-3", 202 | DstID: "node-1", 203 | Port: &node.Port{}, 204 | }) 205 | 206 | var buf bytes.Buffer 207 | 208 | bld.Write(&buf) 209 | 210 | got := buf.String() 211 | want := golden(t, bld.Name(), got) 212 | 213 | if got != want { 214 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /internal/builder/stat.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "io" 7 | "slices" 8 | 9 | "github.com/s0rg/set" 10 | 11 | "github.com/s0rg/decompose/internal/node" 12 | ) 13 | 14 | const minClusters = 2 15 | 16 | // const defaultName = "default" 17 | 18 | type stat struct { 19 | Name string 20 | Count int 21 | } 22 | 23 | type Stat struct { 24 | conns map[string]set.Unordered[string] 25 | ports map[string]int 26 | clusters map[string]int 27 | nodes int 28 | edgesUniq int 29 | edgesTotal int 30 | externals int 31 | } 32 | 33 | func NewStat() *Stat { 34 | return &Stat{ 35 | ports: make(map[string]int), 36 | clusters: make(map[string]int), 37 | conns: make(map[string]set.Unordered[string]), 38 | } 39 | } 40 | 41 | func (s *Stat) Name() string { 42 | return "graph-stats" 43 | } 44 | 45 | func (s *Stat) AddNode(n *node.Node) error { 46 | if n.IsExternal() { 47 | s.externals++ 48 | 49 | return nil 50 | } 51 | 52 | s.nodes++ 53 | 54 | n.Ports.Iter(func(_ string, ports []*node.Port) { 55 | for _, p := range ports { 56 | s.ports[p.Label()]++ 57 | } 58 | }) 59 | 60 | s.conns[n.ID] = make(set.Unordered[string]) 61 | s.clusters[n.Cluster]++ 62 | 63 | return nil 64 | } 65 | 66 | func (s *Stat) isSuitable(srcID, dstID string) (uniq, yes bool) { 67 | sc, ok := s.conns[srcID] 68 | if !ok { 69 | return 70 | } 71 | 72 | dc, ok := s.conns[dstID] 73 | if !ok { 74 | return 75 | } 76 | 77 | uniq = !(sc.Has(dstID) || dc.Has(srcID)) 78 | 79 | sc.Add(dstID) 80 | 81 | return uniq, true 82 | } 83 | 84 | func (s *Stat) AddEdge(e *node.Edge) { 85 | uniq, ok := s.isSuitable(e.SrcID, e.DstID) 86 | if !ok { 87 | return 88 | } 89 | 90 | if uniq { 91 | s.edgesUniq++ 92 | } 93 | 94 | s.edgesTotal++ 95 | } 96 | 97 | func (s *Stat) Write(w io.Writer) error { 98 | fmt.Fprintf(w, "Nodes: %d\n", s.nodes) 99 | fmt.Fprintf(w, "Connections total: %d uniq: %d\n", s.edgesTotal, s.edgesUniq) 100 | 101 | if s.externals > 0 { 102 | fmt.Fprintf(w, "Externals: %d\n", s.externals) 103 | } 104 | 105 | fmt.Fprintln(w, "") 106 | 107 | ports, clusters := s.calcStats() 108 | 109 | if len(clusters) > 0 { 110 | fmt.Fprintf(w, "Clusters %d:\n", len(s.clusters)) 111 | writeStats(w, clusters) 112 | } 113 | 114 | fmt.Fprintln(w, "Ports:") 115 | writeStats(w, ports) 116 | 117 | return nil 118 | } 119 | 120 | func (s *Stat) calcStats() (ports, clusters []*stat) { 121 | ports = make([]*stat, 0, len(s.ports)) 122 | 123 | for k, c := range s.ports { 124 | ports = append(ports, &stat{Name: k, Count: c}) 125 | } 126 | 127 | slices.SortFunc(ports, byCount) 128 | 129 | if len(s.clusters) < minClusters { 130 | return ports, clusters 131 | } 132 | 133 | clusters = make([]*stat, 0, len(s.clusters)) 134 | 135 | for k, c := range s.clusters { 136 | clusters = append(clusters, &stat{Name: k, Count: c}) 137 | } 138 | 139 | slices.SortFunc(clusters, byCount) 140 | 141 | return ports, clusters 142 | } 143 | 144 | func byCount(a, b *stat) int { 145 | return cmp.Compare(b.Count, a.Count) 146 | } 147 | 148 | func writeStats(w io.Writer, s []*stat) { 149 | for _, v := range s { 150 | fmt.Fprintf(w, "\t%s: %d\n", v.Name, v.Count) 151 | } 152 | 153 | fmt.Fprintln(w, "") 154 | } 155 | -------------------------------------------------------------------------------- /internal/builder/stat_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/builder" 9 | "github.com/s0rg/decompose/internal/cluster" 10 | "github.com/s0rg/decompose/internal/graph" 11 | "github.com/s0rg/decompose/internal/node" 12 | ) 13 | 14 | func TestStat(t *testing.T) { 15 | t.Parallel() 16 | 17 | bldr := builder.NewStat() 18 | 19 | if bldr.Name() != "graph-stats" { 20 | t.Fail() 21 | } 22 | 23 | ext := &testEnricher{} 24 | 25 | cfg := &graph.Config{ 26 | Builder: bldr, 27 | Meta: ext, 28 | Proto: graph.ALL, 29 | } 30 | 31 | ldr := graph.NewLoader(cfg) 32 | 33 | const raw = `{ 34 | "name": "test1", 35 | "is_external": false, 36 | "networks": ["test"], 37 | "listen": {"1": [ 38 | {"kind": "tcp", "value": "1"}, 39 | {"kind": "udp", "value": "1"} 40 | ]}, 41 | "connected": { 42 | "test2":[ 43 | {"src": "1", "dst": "2", "port": {"kind": "tcp", "value": "2"}}, 44 | {"src": "1", "dst": "2", "port": {"kind": "udp", "value": "2"}} 45 | ], 46 | "test3":[ 47 | {"src": "1", "dst": "3", "port": {"kind": "tcp", "value": "3"}} 48 | ] 49 | } 50 | } 51 | { 52 | "name": "test2", 53 | "is_external": false, 54 | "networks": ["test"], 55 | "listen": {"2":[ 56 | {"kind": "tcp", "value": "2"}, 57 | {"kind": "udp", "value": "2"} 58 | ]}, 59 | "connected": { 60 | "test1":[ 61 | {"src": "2", "dst": "1", "port": {"kind": "tcp", "value": "1"}}, 62 | {"src": "2", "dst": "1", "port": {"kind": "udp", "value": "1"}} 63 | ], 64 | "test3": [ 65 | {"src": "2", "dst": "3", "port": {"kind": "udp", "value": "3"}} 66 | ] 67 | } 68 | } 69 | { 70 | "name": "test3", 71 | "is_external": true, 72 | "networks": ["test"], 73 | "listen": {"3":[ 74 | {"kind": "tcp", "value": "3"}, 75 | {"kind": "udp", "value": "3"} 76 | ]}, 77 | "connected": {} 78 | }` 79 | 80 | if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { 81 | t.Fatal("load err=", err) 82 | } 83 | 84 | bldr.AddEdge(&node.Edge{SrcID: "test1", DstID: "bad-id"}) 85 | bldr.AddEdge(&node.Edge{SrcID: "bad-id", DstID: "test1"}) 86 | 87 | if err := ldr.Build(); err != nil { 88 | t.Fatal("build err=", err) 89 | } 90 | 91 | var buf bytes.Buffer 92 | 93 | bldr.Write(&buf) 94 | 95 | res := buf.String() 96 | 97 | if !strings.Contains(res, "Nodes: 2") { 98 | t.Fail() 99 | } 100 | 101 | if !strings.Contains(res, "total: 4 uniq: 1") { 102 | t.Fail() 103 | } 104 | 105 | if !strings.Contains(res, "Externals: 1") { 106 | t.Fail() 107 | } 108 | 109 | if strings.Count(res, "tcp:1") != 1 { 110 | t.Fail() 111 | } 112 | 113 | if strings.Count(res, "tcp:2") != 1 { 114 | t.Fail() 115 | } 116 | 117 | if strings.Count(res, "udp:1") != 1 { 118 | t.Fail() 119 | } 120 | 121 | if strings.Count(res, "udp:2") != 1 { 122 | t.Fail() 123 | } 124 | 125 | if strings.Contains(res, "tcp:3") || strings.Contains(res, "udp:3") { 126 | t.Fail() 127 | } 128 | } 129 | 130 | func TestStatCluster(t *testing.T) { 131 | t.Parallel() 132 | 133 | const rules = `[{"name": "foo", "if": "node.Listen.Has('tcp:1')"}, 134 | {"name": "bar", "if": "node.Listen.HasAny('tcp:2')"}]` 135 | 136 | cb := cluster.NewRules(builder.NewStat(), nil) 137 | 138 | if err := cb.FromReader(bytes.NewBufferString(rules)); err != nil { 139 | t.Fatal(err) 140 | } 141 | 142 | cfg := &graph.Config{ 143 | Builder: cb, 144 | Meta: &testEnricher{}, 145 | Proto: graph.ALL, 146 | } 147 | 148 | ldr := graph.NewLoader(cfg) 149 | 150 | const raw = `{ 151 | "name": "test1", 152 | "is_external": false, 153 | "networks": ["test"], 154 | "listen": {"1": [ 155 | {"kind": "tcp", "value": "1"}, 156 | {"kind": "udp", "value": "1"} 157 | ]}, 158 | "connected": { 159 | "test2":[ 160 | {"src": "1", "dst": "2", "port": {"kind": "tcp", "value": "2"}}, 161 | {"src": "1", "dst": "2", "port": {"kind": "udp", "value": "2"}} 162 | ], 163 | "test3":[ 164 | {"src": "1", "dst": "3", "port": {"kind": "tcp", "value": "3"}} 165 | ] 166 | } 167 | } 168 | { 169 | "name": "test2", 170 | "is_external": false, 171 | "networks": ["test"], 172 | "listen": {"2":[ 173 | {"kind": "tcp", "value": "2"}, 174 | {"kind": "udp", "value": "2"} 175 | ]}, 176 | "connected": { 177 | "test1":[ 178 | {"src": "2", "dst": "1", "port": {"kind": "tcp", "value": "1"}}, 179 | {"src": "2", "dst": "1", "port": {"kind": "udp", "value": "1"}} 180 | ], 181 | "test3": [ 182 | {"src": "2", "dst": "3", "port": {"kind": "udp", "value": "3"}} 183 | ] 184 | } 185 | }` 186 | 187 | if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { 188 | t.Fatal("load err=", err) 189 | } 190 | 191 | if err := ldr.Build(); err != nil { 192 | t.Fatal("build err=", err) 193 | } 194 | 195 | var buf bytes.Buffer 196 | 197 | cb.Write(&buf) 198 | 199 | res := buf.String() 200 | 201 | if !strings.Contains(res, "Clusters") { 202 | t.Fail() 203 | } 204 | 205 | if strings.Count(res, "foo: 1") != 1 { 206 | t.Fail() 207 | } 208 | 209 | if strings.Count(res, "bar: 1") != 1 { 210 | t.Fail() 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /internal/builder/testdata/compose-yaml.golden: -------------------------------------------------------------------------------- 1 | services: 2 | "1": 3 | image: node-image 4 | expose: 5 | - "tcp:1" 6 | - "tcp:2" 7 | links: 8 | - "2" 9 | volumes: 10 | - 1_data:dst 11 | networks: 12 | - test-net 13 | environment: 14 | - "FOO=1" 15 | command: 16 | - echo 17 | - '''test 1''' 18 | "2": 19 | image: node-image 20 | expose: 21 | - "tcp:1" 22 | - "tcp:2" 23 | links: 24 | - "1" 25 | volumes: 26 | - 2_data:dst2 27 | networks: 28 | - test-net 29 | environment: 30 | - "FOO=2" 31 | command: 32 | - echo 33 | - '''test 2''' 34 | networks: 35 | test-net: 36 | external: true 37 | volumes: 38 | 1_data: null 39 | 2_data: null 40 | -------------------------------------------------------------------------------- /internal/builder/testdata/csv.golden: -------------------------------------------------------------------------------- 1 | service,listen,outbounds 2 | 1,"tcp:1 3 | tcp:2","2: tcp:2 4 | 3: tcp:3 5 | " 6 | 2,tcp:2,"1: tcp:1; tcp:2; tcp:3 7 | 3: tcp:3 8 | " 9 | 3,tcp:3, 10 | -------------------------------------------------------------------------------- /internal/builder/testdata/graphviz-dot.golden: -------------------------------------------------------------------------------- 1 | digraph { 2 | 3 | n4[color="gray",label="external: 2"]; 4 | n1[color="black",label="1"]; 5 | n2[color="black",label="2"]; 6 | n3[color="black",label="3"]; 7 | n4->n1[label="tcp:1,tcp:2,tcp:3,tcp:2,tcp:3"]; 8 | n1->n2[label="tcp:1,tcp:1,tcp:2,tcp:2"]; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /internal/builder/testdata/plant-uml.golden: -------------------------------------------------------------------------------- 1 | @startuml 2 | skinparam componentStyle rectangle 3 | skinparam nodesep 5 4 | skinparam ranksep 5 5 | component "1" as id_b8d1bdeb90c390c3f601 { 6 | component "" as id_b8d1bdeb90c390c3f601 { 7 | portin "tcp:1" as id_baafedd79f8dae92d601 8 | portin "tcp:2" as id_87acedd79fedad92d601 9 | portin "tcp:5" as id_eea1edd79f8dad92d601 10 | } 11 | portin "tcp:1" as id_baafedd79f8dae92d601 12 | portin "tcp:2" as id_87acedd79fedad92d601 13 | } 14 | component "2" as id_d1dbbdeb90a391c3f601 { 15 | component "" as id_d1dbbdeb90a391c3f601 { 16 | portin "tcp:1" as id_a9bca8f08cc3a2cf18 17 | portin "tcp:2" as id_90b2a8f08ce3a1cf18 18 | } 19 | portin "tcp:1" as id_a9bca8f08cc3a2cf18 20 | portin "tcp:2" as id_90b2a8f08ce3a1cf18 21 | } 22 | component "3" as id_e2bc86b0c8c9ebb1af01 { 23 | component "" as id_e2bc86b0c8c9ebb1af01 { 24 | portin "tcp:1" as id_9885fdc88aa7fef313 25 | portin "tcp:2" as id_b18ffdc88a87fff313 26 | } 27 | portin "tcp:1" as id_9885fdc88aa7fef313 28 | portin "tcp:2" as id_b18ffdc88a87fff313 29 | } 30 | cloud "Externals" as ext { 31 | component "ext2" as id_a7fadbdd96b2cbabd101 { 32 | portin "tcp:443" as id_e1dee4c2d0c6cdbcfa01 33 | } 34 | } 35 | component "ext2" as id_a7fadbdd96b2cbabd101 { 36 | portin "tcp:443" as id_e1dee4c2d0c6cdbcfa01 37 | } 38 | } 39 | } 40 | 41 | id_baafedd79f8dae92d601 -> id_baafedd79f8dae92d601 42 | id_87acedd79fedad92d601 -> id_87acedd79fedad92d601 43 | id_b8d1bdeb90c390c3f601 --> id_eea1edd79f8dad92d601 44 | id_b8d1bdeb90c390c3f601 -----> id_90b2a8f08ce3a1cf18: tcp:2 45 | id_a9bca8f08cc3a2cf18 -> id_a9bca8f08cc3a2cf18 46 | id_90b2a8f08ce3a1cf18 -> id_90b2a8f08ce3a1cf18 47 | id_d1dbbdeb90a391c3f601 -----> id_baafedd79f8dae92d601: tcp:1 48 | id_d1dbbdeb90a391c3f601 -----> id_87acedd79fedad92d601: tcp:2 49 | id_d1dbbdeb90a391c3f601 -----> id_d4a8edd79fcdad92d601: tcp:3 50 | @enduml 51 | -------------------------------------------------------------------------------- /internal/builder/testdata/structurizr-dsl.golden: -------------------------------------------------------------------------------- 1 | workspace { 2 | name "de-composed system" 3 | 4 | model { 5 | default = softwareSystem "default" { 6 | tags "3" 7 | node_3 = container "3" { 8 | description "info 3" 9 | technology "node-image" 10 | tags "3,listen:tcp:1,listen:tcp:2,net:test-net" 11 | node_3_ = component "" { 12 | tags "listen:tcp:1,listen:tcp:2" 13 | } 14 | } 15 | } 16 | c1 = softwareSystem "c1" { 17 | tags "1,2" 18 | node_1 = container "1" { 19 | description "info 1 \ 20 | docs-url \ 21 | repo-url" 22 | technology "node-image" 23 | tags "1,listen:tcp:1,listen:tcp:2,net:test-net" 24 | node_1_ = component "" { 25 | tags "listen:tcp:1,listen:tcp:2" 26 | } 27 | } 28 | node_2 = container "2" { 29 | description "info 2" 30 | technology "node-image" 31 | tags "2,listen:tcp:1,listen:tcp:2,net:test-net" 32 | node_2_ = component "" { 33 | tags "listen:tcp:1,listen:tcp:2" 34 | } 35 | } 36 | } 37 | c2 = softwareSystem "c2" { 38 | tags "ext2" 39 | ext2 = container "ext2" { 40 | tags "external,listen:tcp:2" 41 | ext2_ = component "" { 42 | tags "listen:tcp:2" 43 | } 44 | } 45 | } 46 | 47 | c1 -> c2 ":" { 48 | } 49 | c1 -> default ":" { 50 | } 51 | default -> c2 ":" { 52 | } 53 | } 54 | 55 | views { 56 | systemContext default "systemContext_default" { 57 | include * 58 | include c1 59 | include c2 60 | autoLayout 61 | } 62 | container default "container_default" { 63 | include * 64 | autoLayout 65 | } 66 | component node_3 "component_node_3" { 67 | include * 68 | autoLayout 69 | } 70 | systemContext c1 "systemContext_c1" { 71 | include * 72 | autoLayout 73 | } 74 | container c1 "container_c1" { 75 | include * 76 | autoLayout 77 | } 78 | component node_1 "component_node_1" { 79 | include * 80 | autoLayout 81 | } 82 | component node_2 "component_node_2" { 83 | include * 84 | autoLayout 85 | } 86 | systemContext c2 "systemContext_c2" { 87 | include * 88 | autoLayout 89 | } 90 | container c2 "container_c2" { 91 | include * 92 | autoLayout 93 | } 94 | component ext2 "component_ext2" { 95 | include * 96 | autoLayout 97 | } 98 | 99 | styles { 100 | element "Element" { 101 | metadata true 102 | description true 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/builder/testdata/text-tree.golden: -------------------------------------------------------------------------------- 1 | . 2 | ├─ 1 3 | │ external: false 4 | │ image: node-image 5 | │ tags: 1 6 | │ cmd: 'echo 'test 1'' 7 | │ listen: tcp:1, tcp:2 8 | │ networks: test-net 9 | │ │ 10 | │ ├─ 2: tcp:2 11 | │ └─ 3: tcp:3 12 | │ 13 | ├─ 2 14 | │ external: false 15 | │ tags: 2 16 | │ cmd: 'echo 'test 2'' 17 | │ listen: tcp:2 18 | │ networks: test-net 19 | │ │ 20 | │ ├─ 1: tcp:1, tcp:2, tcp:3 21 | │ └─ 3: tcp:3 22 | │ 23 | └─ 3 24 | external: false 25 | tags: 3 26 | cmd: 'echo 'test 3'' 27 | listen: tcp:3 28 | networks: test-net 29 | -------------------------------------------------------------------------------- /internal/builder/tree.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "io" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | const ( 14 | space = " " 15 | symRoot = ". " 16 | symEdge = "├─" 17 | symLine = "│ " 18 | symEnd = "└─" 19 | ) 20 | 21 | type Tree struct { 22 | j *JSON 23 | } 24 | 25 | func NewTree() *Tree { 26 | return &Tree{ 27 | j: NewJSON(), 28 | } 29 | } 30 | 31 | func (t *Tree) Name() string { 32 | return "text-tree" 33 | } 34 | 35 | func (t *Tree) AddNode(n *node.Node) error { 36 | return t.j.AddNode(n) 37 | } 38 | 39 | func (t *Tree) AddEdge(e *node.Edge) { 40 | t.j.AddEdge(e) 41 | } 42 | 43 | func (t *Tree) Write(w io.Writer) error { 44 | fmt.Fprintln(w, symRoot) 45 | 46 | t.j.Sorted(func(n *node.JSON, last bool) { 47 | writeNode(w, n, last) 48 | }) 49 | 50 | return nil 51 | } 52 | 53 | func writeNode(w io.Writer, n *node.JSON, last bool) { 54 | var next string 55 | 56 | if last { 57 | fmt.Fprint(w, symEnd) 58 | 59 | next = space 60 | } else { 61 | fmt.Fprint(w, symEdge) 62 | 63 | next = symLine 64 | } 65 | 66 | fmt.Fprint(w, " ") 67 | fmt.Fprintln(w, n.Name) 68 | 69 | fmt.Fprint(w, next, " ") 70 | fmt.Fprintf(w, "external: %t\n", n.IsExternal) 71 | 72 | if n.Image != nil { 73 | fmt.Fprint(w, next, " ") 74 | fmt.Fprintln(w, "image:", *n.Image) 75 | } 76 | 77 | if len(n.Tags) > 0 { 78 | fmt.Fprint(w, next, " ") 79 | fmt.Fprintln(w, "tags:", strings.Join(n.Tags, ", ")) 80 | } 81 | 82 | if len(n.Container.Cmd) > 0 { 83 | fmt.Fprint(w, next, " ") 84 | fmt.Fprintf(w, "cmd: '%s'\n", strings.Join(n.Container.Cmd, " ")) 85 | } 86 | 87 | fmt.Fprint(w, next, " ") 88 | fmt.Fprintln(w, "listen:", joinListeners(n.Listen, ", ")) 89 | 90 | if len(n.Networks) > 0 { 91 | fmt.Fprint(w, next, " ") 92 | fmt.Fprintln(w, "networks:", strings.Join(n.Networks, ", ")) 93 | } 94 | 95 | var ( 96 | cur int 97 | lst = len(n.Connected) - 1 98 | ) 99 | 100 | if len(n.Connected) > 0 { 101 | fmt.Fprint(w, next, " ", symLine, "\n") 102 | } 103 | 104 | dstOrder := make([]string, 0, len(n.Connected)) 105 | 106 | for dst := range n.Connected { 107 | dstOrder = append(dstOrder, dst) 108 | } 109 | 110 | slices.SortFunc(dstOrder, cmp.Compare) 111 | 112 | for _, dst := range dstOrder { 113 | ports := n.Connected[dst] 114 | 115 | fmt.Fprint(w, next, " ") 116 | 117 | if cur == lst { 118 | fmt.Fprint(w, symEnd) 119 | } else { 120 | fmt.Fprint(w, symEdge) 121 | } 122 | 123 | fmt.Fprintf(w, " %s: %s\n", dst, joinConnections(ports, ", ")) 124 | 125 | cur++ 126 | } 127 | 128 | if !last { 129 | fmt.Fprintln(w, next) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /internal/builder/tree_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/s0rg/decompose/internal/builder" 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | func TestTreeGolden(t *testing.T) { 12 | t.Parallel() 13 | 14 | bld := builder.NewTree() 15 | 16 | _ = bld.AddNode(&node.Node{ 17 | ID: "node-1", 18 | Name: "1", 19 | Image: "node-image", 20 | Ports: makeTestPorts([]*node.Port{ 21 | {Kind: "tcp", Value: "1"}, 22 | {Kind: "tcp", Value: "2"}, 23 | }...), 24 | Networks: []string{"test-net"}, 25 | Meta: &node.Meta{ 26 | Info: "info 1", 27 | Tags: []string{"1"}, 28 | }, 29 | Container: node.Container{ 30 | Cmd: []string{"echo", "'test 1'"}, 31 | Env: []string{"FOO=1"}, 32 | }, 33 | }) 34 | _ = bld.AddNode(&node.Node{ 35 | ID: "node-2", 36 | Name: "2", 37 | Ports: makeTestPorts([]*node.Port{ 38 | {Kind: "tcp", Value: "2"}, 39 | }...), 40 | Networks: []string{"test-net"}, 41 | Meta: &node.Meta{ 42 | Info: "info 2", 43 | Tags: []string{"2"}, 44 | }, 45 | Container: node.Container{ 46 | Cmd: []string{"echo", "'test 2'"}, 47 | Env: []string{"FOO=2"}, 48 | }, 49 | }) 50 | _ = bld.AddNode(&node.Node{ 51 | ID: "node-3", 52 | Name: "3", 53 | Ports: makeTestPorts([]*node.Port{ 54 | {Kind: "tcp", Value: "3"}, 55 | }...), 56 | Networks: []string{"test-net"}, 57 | Meta: &node.Meta{ 58 | Info: "info 3", 59 | Tags: []string{"3"}, 60 | }, 61 | Container: node.Container{ 62 | Cmd: []string{"echo", "'test 3'"}, 63 | Env: []string{"FOO=3"}, 64 | }, 65 | }) 66 | 67 | bld.AddEdge(&node.Edge{ 68 | SrcID: "node-2", 69 | DstID: "node-1", 70 | Port: &node.Port{Kind: "tcp", Value: "1"}, 71 | }) 72 | 73 | bld.AddEdge(&node.Edge{ 74 | SrcID: "node-2", 75 | DstID: "node-1", 76 | Port: &node.Port{Kind: "tcp", Value: "2"}, 77 | }) 78 | 79 | bld.AddEdge(&node.Edge{ 80 | SrcID: "node-2", 81 | DstID: "node-1", 82 | Port: &node.Port{Kind: "tcp", Value: "3"}, 83 | }) 84 | 85 | bld.AddEdge(&node.Edge{ 86 | SrcID: "node-2", 87 | DstID: "node-3", 88 | Port: &node.Port{Kind: "tcp", Value: "3"}, 89 | }) 90 | 91 | bld.AddEdge(&node.Edge{ 92 | SrcID: "node-1", 93 | DstID: "node-3", 94 | Port: &node.Port{Kind: "tcp", Value: "3"}, 95 | }) 96 | 97 | bld.AddEdge(&node.Edge{ 98 | SrcID: "node-1", 99 | DstID: "node-2", 100 | Port: &node.Port{Kind: "tcp", Value: "2"}, 101 | }) 102 | 103 | bld.AddEdge(&node.Edge{ 104 | SrcID: "node-1", 105 | DstID: "3", 106 | Port: &node.Port{Kind: "tcp", Value: "3"}, 107 | }) 108 | 109 | bld.AddEdge(&node.Edge{ 110 | SrcID: "3", 111 | DstID: "node-1", 112 | Port: &node.Port{Kind: "tcp", Value: "3"}, 113 | }) 114 | 115 | var buf bytes.Buffer 116 | 117 | bld.Write(&buf) 118 | 119 | got := buf.String() 120 | want := golden(t, bld.Name(), got) 121 | 122 | if got != want { 123 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /internal/builder/yaml.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "gopkg.in/yaml.v3" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | "github.com/s0rg/set" 12 | ) 13 | 14 | const volumeSuffix = "_data" 15 | 16 | type compose struct { 17 | Services map[string]*service `yaml:"services"` 18 | Networks map[string]*network `yaml:"networks"` 19 | Volumes map[string]any `yaml:"volumes"` 20 | } 21 | 22 | type network struct { 23 | External bool `yaml:"external"` 24 | } 25 | 26 | type service struct { 27 | Image string `yaml:"image"` 28 | Expose yaml.Node `yaml:"expose"` 29 | Links []string `yaml:"links"` 30 | Volumes []string `yaml:"volumes"` 31 | Networks []string `yaml:"networks"` 32 | Environment yaml.Node `yaml:"environment"` 33 | Command []string `yaml:"command"` 34 | } 35 | 36 | type YAML struct { 37 | state *compose 38 | idmap map[string]string 39 | } 40 | 41 | func NewYAML() *YAML { 42 | return &YAML{ 43 | state: &compose{ 44 | Services: make(map[string]*service), 45 | Networks: make(map[string]*network), 46 | Volumes: make(map[string]any), 47 | }, 48 | idmap: make(map[string]string), 49 | } 50 | } 51 | 52 | func (y *YAML) Name() string { 53 | return "compose-yaml" 54 | } 55 | 56 | func (y *YAML) AddNode(n *node.Node) error { 57 | if n.IsExternal() { 58 | return nil 59 | } 60 | 61 | svc := &service{ 62 | Image: n.Image, 63 | Networks: n.Networks, 64 | } 65 | 66 | if len(n.Container.Cmd) > 0 { 67 | svc.Command = n.Container.Cmd 68 | } 69 | 70 | if len(n.Container.Env) > 0 { 71 | yn := yaml.Node{ 72 | Kind: yaml.SequenceNode, 73 | } 74 | 75 | for _, ev := range n.Container.Env { 76 | yn.Content = append(yn.Content, &yaml.Node{ 77 | Kind: yaml.ScalarNode, 78 | Style: yaml.DoubleQuotedStyle, 79 | Value: ev, 80 | }) 81 | } 82 | 83 | svc.Environment = yn 84 | } 85 | 86 | for _, name := range n.Networks { 87 | y.state.Networks[name] = &network{ 88 | External: true, 89 | } 90 | } 91 | 92 | svc.Expose = yaml.Node{ 93 | Kind: yaml.SequenceNode, 94 | } 95 | 96 | n.Ports.Iter(func(_ string, plist []*node.Port) { 97 | for _, p := range plist { 98 | svc.Expose.Content = append(svc.Expose.Content, &yaml.Node{ 99 | Kind: yaml.ScalarNode, 100 | Style: yaml.DoubleQuotedStyle, 101 | Value: p.Label(), 102 | }) 103 | } 104 | }) 105 | 106 | svc.Volumes = make([]string, len(n.Volumes)) 107 | 108 | for i, v := range n.Volumes { 109 | src := v.Src 110 | 111 | if v.Type == "volume" { 112 | src = strings.ReplaceAll(n.Name, "-", "_") + volumeSuffix 113 | y.state.Volumes[src] = nil 114 | } 115 | 116 | svc.Volumes[i] = src + ":" + v.Dst 117 | } 118 | 119 | y.idmap[n.ID] = n.Name 120 | y.state.Services[n.Name] = svc 121 | 122 | return nil 123 | } 124 | 125 | func (y *YAML) AddEdge(e *node.Edge) { 126 | name, ok := y.idmap[e.SrcID] 127 | if !ok { 128 | return 129 | } 130 | 131 | svc := y.state.Services[name] 132 | 133 | name, ok = y.idmap[e.DstID] 134 | if !ok { 135 | return 136 | } 137 | 138 | svc.Links = append(svc.Links, name) 139 | } 140 | 141 | func (y *YAML) Write(w io.Writer) error { 142 | enc := yaml.NewEncoder(w) 143 | defer enc.Close() 144 | 145 | s := make(set.Unordered[string]) 146 | 147 | // de-dup links 148 | for _, svc := range y.state.Services { 149 | set.Load(s, svc.Links...) 150 | svc.Links = set.ToSlice(s) 151 | s.Clear() 152 | } 153 | 154 | if err := enc.Encode(y.state); err != nil { 155 | return fmt.Errorf("encode: %w", err) 156 | } 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /internal/builder/yaml_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/builder" 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | func TestYAMLGolden(t *testing.T) { 13 | t.Parallel() 14 | 15 | bld := builder.NewYAML() 16 | 17 | _ = bld.AddNode(&node.Node{ 18 | ID: "node-1", 19 | Name: "1", 20 | Image: "node-image", 21 | Ports: makeTestPorts([]*node.Port{ 22 | {Kind: "tcp", Value: "1"}, 23 | {Kind: "tcp", Value: "2"}, 24 | }...), 25 | Networks: []string{"test-net"}, 26 | Meta: &node.Meta{ 27 | Info: "info 1", 28 | Tags: []string{"1"}, 29 | }, 30 | Container: node.Container{ 31 | Cmd: []string{"echo", "'test 1'"}, 32 | Env: []string{"FOO=1"}, 33 | }, 34 | Volumes: []*node.Volume{ 35 | {Type: "volume", Src: "src", Dst: "dst"}, 36 | }, 37 | }) 38 | _ = bld.AddNode(&node.Node{ 39 | ID: "node-2", 40 | Name: "2", 41 | Image: "node-image", 42 | Ports: makeTestPorts([]*node.Port{ 43 | {Kind: "tcp", Value: "1"}, 44 | {Kind: "tcp", Value: "2"}, 45 | }...), 46 | Networks: []string{"test-net"}, 47 | Meta: &node.Meta{ 48 | Info: "info 2", 49 | Tags: []string{"2"}, 50 | }, 51 | Container: node.Container{ 52 | Cmd: []string{"echo", "'test 2'"}, 53 | Env: []string{"FOO=2"}, 54 | }, 55 | Volumes: []*node.Volume{ 56 | {Type: "volume", Src: "src2", Dst: "dst2"}, 57 | }, 58 | }) 59 | 60 | _ = bld.AddNode(&node.Node{ 61 | ID: "2", 62 | Name: "2", 63 | Ports: makeTestPorts([]*node.Port{ 64 | {Kind: "tcp", Value: "2"}, 65 | }...), 66 | }) 67 | 68 | bld.AddEdge(&node.Edge{ 69 | SrcID: "2", 70 | DstID: "node-1", 71 | Port: &node.Port{Kind: "tcp", Value: "1"}, 72 | }) 73 | 74 | bld.AddEdge(&node.Edge{ 75 | SrcID: "2", 76 | DstID: "node-1", 77 | Port: &node.Port{Kind: "tcp", Value: "2"}, 78 | }) 79 | 80 | bld.AddEdge(&node.Edge{ 81 | SrcID: "2", 82 | DstID: "node-1", 83 | Port: &node.Port{Kind: "tcp", Value: "3"}, 84 | }) 85 | 86 | bld.AddEdge(&node.Edge{ 87 | SrcID: "node-2", 88 | DstID: "node-1", 89 | Port: &node.Port{Kind: "tcp", Value: "2"}, 90 | }) 91 | 92 | bld.AddEdge(&node.Edge{ 93 | SrcID: "node-1", 94 | DstID: "node-2", 95 | Port: &node.Port{Kind: "tcp", Value: "2"}, 96 | }) 97 | 98 | bld.AddEdge(&node.Edge{ 99 | SrcID: "node-1", 100 | DstID: "2", 101 | Port: &node.Port{Kind: "tcp", Value: "2"}, 102 | }) 103 | 104 | bld.AddEdge(&node.Edge{ 105 | SrcID: "node-1", 106 | DstID: "2", 107 | Port: &node.Port{Kind: "tcp", Value: "3"}, 108 | }) 109 | 110 | bld.AddEdge(&node.Edge{ 111 | SrcID: "node-1", 112 | DstID: "3", 113 | Port: &node.Port{Kind: "tcp", Value: "3"}, 114 | }) 115 | 116 | bld.AddEdge(&node.Edge{ 117 | SrcID: "3", 118 | DstID: "node-1", 119 | Port: &node.Port{Kind: "tcp", Value: "3"}, 120 | }) 121 | 122 | var buf bytes.Buffer 123 | 124 | bld.Write(&buf) 125 | 126 | got := buf.String() 127 | want := golden(t, bld.Name(), got) 128 | 129 | if got != want { 130 | t.Errorf("Want:\n%s\nGot:\n%s", want, got) 131 | } 132 | } 133 | 134 | func TestYAMLWriteError(t *testing.T) { 135 | t.Parallel() 136 | 137 | bldr := builder.NewYAML() 138 | testErr := errors.New("test-error") 139 | errW := &errWriter{Err: testErr} 140 | 141 | _ = bldr.AddNode(&node.Node{ 142 | ID: "#", 143 | Name: "#", 144 | Image: "node-image", 145 | Ports: &node.Ports{}, 146 | Networks: []string{"test-net"}, 147 | Meta: &node.Meta{ 148 | Info: "info 1", 149 | Tags: []string{"1"}, 150 | }, 151 | Container: node.Container{ 152 | Cmd: []string{"echo", "'test 1'"}, 153 | Env: []string{"FOO=1"}, 154 | }, 155 | Volumes: []*node.Volume{ 156 | {Type: "volume", Src: "src", Dst: "dst"}, 157 | }, 158 | }) 159 | 160 | if err := bldr.Write(errW); !errors.Is(err, testErr) { 161 | t.Log(err) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/client/defaults.go: -------------------------------------------------------------------------------- 1 | //go:build !test 2 | 3 | package client 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/docker/docker/client" 14 | "github.com/prometheus/procfs" 15 | 16 | "github.com/s0rg/decompose/internal/graph" 17 | "github.com/s0rg/set" 18 | ) 19 | 20 | const ( 21 | pingTimeout = time.Second 22 | procENV = "IN_DOCKER_PROC_ROOT" 23 | procDefault = "/proc" 24 | 25 | // from net/tcp_states.h. 26 | tcpEstablished = uint64(1) 27 | tcpListen = uint64(10) 28 | ) 29 | 30 | var procROOT = procDefault 31 | 32 | func Default() (rv DockerClient, err error) { 33 | var dc *client.Client 34 | 35 | dc, err = client.NewClientWithOpts( 36 | client.FromEnv, 37 | client.WithAPIVersionNegotiation(), 38 | ) 39 | if err != nil { 40 | return nil, fmt.Errorf("docker: %w", err) 41 | } 42 | 43 | ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) 44 | defer cancel() 45 | 46 | if _, err = dc.Ping(ctx); err != nil { 47 | return nil, fmt.Errorf("ping: %w", err) 48 | } 49 | 50 | if root := os.Getenv(procENV); root != "" { 51 | procROOT = filepath.Join(root, procDefault) 52 | } 53 | 54 | return dc, nil 55 | } 56 | 57 | func checkState(state uint64) (listener, valid bool) { 58 | if state == tcpListen { 59 | return true, true 60 | } 61 | 62 | if state == tcpEstablished { 63 | return false, true 64 | } 65 | 66 | return false, false 67 | } 68 | 69 | func scanTCP( 70 | pfs procfs.FS, 71 | name string, 72 | onconn func(*graph.Connection), 73 | ) (err error) { 74 | tcp4, err := pfs.NetTCP() 75 | if err != nil { 76 | return fmt.Errorf("procfs/tcp4: %w", err) 77 | } 78 | 79 | for _, s := range tcp4 { 80 | listener, ok := checkState(s.St) 81 | if !ok { 82 | continue 83 | } 84 | 85 | onconn(&graph.Connection{ 86 | Process: name, 87 | Inode: s.Inode, 88 | SrcIP: s.LocalAddr, 89 | DstIP: s.RemAddr, 90 | SrcPort: int(s.LocalPort), 91 | DstPort: int(s.RemPort), 92 | Proto: graph.TCP, 93 | Listen: listener, 94 | }) 95 | } 96 | 97 | tcp6, err := pfs.NetTCP6() 98 | if err != nil { 99 | return fmt.Errorf("procfs/tcp6: %w", err) 100 | } 101 | 102 | for _, s := range tcp6 { 103 | listener, ok := checkState(s.St) 104 | if !ok { 105 | continue 106 | } 107 | 108 | onconn(&graph.Connection{ 109 | Process: name, 110 | Inode: s.Inode, 111 | SrcIP: s.LocalAddr, 112 | DstIP: s.RemAddr, 113 | SrcPort: int(s.LocalPort), 114 | DstPort: int(s.RemPort), 115 | Proto: graph.TCP, 116 | Listen: listener, 117 | }) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func scanUDP( 124 | pfs procfs.FS, 125 | name string, 126 | onconn func(*graph.Connection), 127 | ) (err error) { 128 | udp4, err := pfs.NetUDP() 129 | if err != nil { 130 | return fmt.Errorf("procfs/udp4: %w", err) 131 | } 132 | 133 | for _, s := range udp4 { 134 | onconn(&graph.Connection{ 135 | Process: name, 136 | Inode: s.Inode, 137 | SrcIP: s.LocalAddr, 138 | DstIP: s.RemAddr, 139 | SrcPort: int(s.LocalPort), 140 | DstPort: int(s.RemPort), 141 | Proto: graph.UDP, 142 | }) 143 | } 144 | 145 | udp6, err := pfs.NetUDP6() 146 | if err != nil { 147 | return fmt.Errorf("procfs/udp6: %w", err) 148 | } 149 | 150 | for _, s := range udp6 { 151 | onconn(&graph.Connection{ 152 | Process: name, 153 | Inode: s.Inode, 154 | SrcIP: s.LocalAddr, 155 | DstIP: s.RemAddr, 156 | SrcPort: int(s.LocalPort), 157 | DstPort: int(s.RemPort), 158 | Proto: graph.UDP, 159 | }) 160 | } 161 | 162 | return nil 163 | } 164 | 165 | func scanUNIX( 166 | pfs procfs.FS, 167 | name string, 168 | onconn func(*graph.Connection), 169 | ) (err error) { 170 | unix, err := pfs.NetUNIX() 171 | if err != nil { 172 | return fmt.Errorf("procfs/unix: %w", err) 173 | } 174 | 175 | for _, s := range unix.Rows { 176 | onconn(&graph.Connection{ 177 | Process: name, 178 | Inode: s.Inode, 179 | Path: s.Path, 180 | Listen: s.Flags != 0, 181 | Proto: graph.UNIX, 182 | }) 183 | } 184 | 185 | return nil 186 | } 187 | 188 | func processInfo(pid int) ( 189 | name string, 190 | err error, 191 | ) { 192 | pfs, err := procfs.NewFS(procROOT) 193 | if err != nil { 194 | return "", fmt.Errorf("procfs: %w", err) 195 | } 196 | 197 | proc, err := pfs.Proc(pid) 198 | if err != nil { 199 | return "", fmt.Errorf("procfs/pid: %w", err) 200 | } 201 | 202 | name, err = proc.Executable() 203 | if err != nil { 204 | return "", fmt.Errorf("procfs/executable: %w", err) 205 | } 206 | 207 | return filepath.Base(name), nil 208 | } 209 | 210 | func Inodes( 211 | pid int, 212 | cb func(uint64), 213 | ) error { 214 | pfs, err := procfs.NewFS(procROOT) 215 | if err != nil { 216 | return fmt.Errorf("procfs: %w", err) 217 | } 218 | 219 | proc, err := pfs.Proc(pid) 220 | if err != nil { 221 | return fmt.Errorf("procfs/pid: %w", err) 222 | } 223 | 224 | fds, err := proc.FileDescriptorsInfo() 225 | if err != nil { 226 | return fmt.Errorf("procfs/descriptors: %w", err) 227 | } 228 | 229 | seen := make(set.Unordered[uint64]) 230 | 231 | for _, f := range fds { 232 | ino, err := strconv.ParseUint(f.Ino, 10, 64) 233 | if err != nil { 234 | continue 235 | } 236 | 237 | if seen.Add(ino) { 238 | cb(ino) 239 | } 240 | } 241 | 242 | return nil 243 | } 244 | 245 | func Nsenter( 246 | pid int, 247 | proto graph.NetProto, 248 | onconn func(int, *graph.Connection), 249 | ) ( 250 | err error, 251 | ) { 252 | name, err := processInfo(pid) 253 | if err != nil { 254 | return fmt.Errorf("procfs: %w", err) 255 | } 256 | 257 | connWithPid := func(c *graph.Connection) { 258 | onconn(pid, c) 259 | } 260 | 261 | fs, err := procfs.NewFS(filepath.Join(procROOT, strconv.Itoa(pid))) 262 | if err != nil { 263 | return fmt.Errorf("procfs/net: %w", err) 264 | } 265 | 266 | if proto.Has(graph.TCP) { 267 | if err = scanTCP(fs, name, connWithPid); err != nil { 268 | return fmt.Errorf("scan: %w", err) 269 | } 270 | } 271 | 272 | if proto.Has(graph.UDP) { 273 | if err = scanUDP(fs, name, connWithPid); err != nil { 274 | return fmt.Errorf("scan: %w", err) 275 | } 276 | } 277 | 278 | if proto.Has(graph.UNIX) { 279 | if err = scanUNIX(fs, name, connWithPid); err != nil { 280 | return fmt.Errorf("scan: %w", err) 281 | } 282 | } 283 | 284 | return nil 285 | } 286 | -------------------------------------------------------------------------------- /internal/client/helpers_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "time" 8 | 9 | "github.com/docker/docker/api/types" 10 | "github.com/docker/docker/api/types/container" 11 | ) 12 | 13 | func voidProgress(_, _ int) {} 14 | 15 | type clientMock struct { 16 | Err error 17 | OnList func() []types.Container 18 | OnInspect func() types.ContainerJSON 19 | OnExecCreate func() types.IDResponse 20 | OnExecAttach func() types.HijackedResponse 21 | OnContainerTop func() container.ContainerTopOKBody 22 | } 23 | 24 | func (cm *clientMock) ContainerTop( 25 | _ context.Context, 26 | _ string, 27 | _ []string, 28 | ) (rv container.ContainerTopOKBody, err error) { 29 | if cm.Err != nil { 30 | err = cm.Err 31 | 32 | return 33 | } 34 | 35 | return cm.OnContainerTop(), nil 36 | } 37 | 38 | func (cm *clientMock) ContainerList( 39 | _ context.Context, 40 | _ container.ListOptions, 41 | ) (rv []types.Container, err error) { 42 | if cm.Err != nil { 43 | err = cm.Err 44 | 45 | return 46 | } 47 | 48 | return cm.OnList(), nil 49 | } 50 | 51 | func (cm *clientMock) ContainerInspect( 52 | _ context.Context, 53 | _ string, 54 | ) (rv types.ContainerJSON, err error) { 55 | if cm.Err != nil { 56 | err = cm.Err 57 | 58 | return 59 | } 60 | 61 | return cm.OnInspect(), nil 62 | } 63 | 64 | func (cm *clientMock) ContainerExecCreate( 65 | _ context.Context, 66 | _ string, 67 | _ container.ExecOptions, 68 | ) (rv types.IDResponse, err error) { 69 | if cm.Err != nil { 70 | err = cm.Err 71 | 72 | return 73 | } 74 | 75 | return cm.OnExecCreate(), nil 76 | } 77 | 78 | func (cm *clientMock) ContainerExecAttach( 79 | _ context.Context, 80 | _ string, 81 | _ container.ExecStartOptions, 82 | ) (rv types.HijackedResponse, err error) { 83 | if cm.Err != nil { 84 | err = cm.Err 85 | 86 | return 87 | } 88 | 89 | return cm.OnExecAttach(), nil 90 | } 91 | 92 | func (cm *clientMock) Ping(_ context.Context) (rv types.Ping, err error) { 93 | if cm.Err != nil { 94 | err = cm.Err 95 | 96 | return 97 | } 98 | 99 | return rv, nil 100 | } 101 | 102 | func (cm *clientMock) Close() (err error) { 103 | if cm.Err != nil { 104 | err = cm.Err 105 | } 106 | 107 | return 108 | } 109 | 110 | type connMock struct { 111 | Err error 112 | } 113 | 114 | func (cnm *connMock) Read(_ []byte) (n int, err error) { 115 | if cnm.Err != nil { 116 | return 0, cnm.Err 117 | } 118 | 119 | return 0, io.EOF 120 | } 121 | 122 | func (cnm *connMock) Write(b []byte) (n int, err error) { 123 | return len(b), nil 124 | } 125 | 126 | func (cnm *connMock) Close() error { 127 | return cnm.Err 128 | } 129 | 130 | func (cnm *connMock) LocalAddr() (rv net.Addr) { return } 131 | func (cnm *connMock) RemoteAddr() (rv net.Addr) { return } 132 | func (cnm *connMock) SetDeadline(_ time.Time) error { return nil } 133 | func (cnm *connMock) SetReadDeadline(_ time.Time) error { return nil } 134 | func (cnm *connMock) SetWriteDeadline(_ time.Time) error { return nil } 135 | -------------------------------------------------------------------------------- /internal/client/inodes.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/s0rg/set" 5 | ) 6 | 7 | type InodesMap struct { 8 | m map[string]map[int]set.Unordered[uint64] 9 | u map[string]map[int]set.Unordered[uint64] 10 | n map[string]map[int]string 11 | l map[string]map[int]string 12 | } 13 | 14 | type item struct { 15 | Cid string 16 | Pid int 17 | } 18 | 19 | func (m *InodesMap) AddProcess(containerID string, pid int, name string) { 20 | if m.n == nil { 21 | m.n = make(map[string]map[int]string) 22 | } 23 | 24 | names, ok := m.n[containerID] 25 | if !ok { 26 | names = make(map[int]string) 27 | m.n[containerID] = names 28 | } 29 | 30 | names[pid] = name 31 | } 32 | 33 | func (m *InodesMap) AddInode(containerID string, pid int, inode uint64) { 34 | if m.m == nil { 35 | m.m = make(map[string]map[int]set.Unordered[uint64]) 36 | } 37 | 38 | pids, ok := m.m[containerID] 39 | if !ok { 40 | pids = make(map[int]set.Unordered[uint64]) 41 | m.m[containerID] = pids 42 | } 43 | 44 | inodes, ok := pids[pid] 45 | if !ok { 46 | inodes = make(set.Unordered[uint64]) 47 | pids[pid] = inodes 48 | } 49 | 50 | inodes.Add(inode) 51 | } 52 | 53 | func (m *InodesMap) MarkListener(containerID string, pid int, path string) { 54 | if m.l == nil { 55 | m.l = make(map[string]map[int]string) 56 | } 57 | 58 | pids, ok := m.l[containerID] 59 | if !ok { 60 | pids = make(map[int]string) 61 | m.l[containerID] = pids 62 | } 63 | 64 | pids[pid] = path 65 | } 66 | 67 | func (m *InodesMap) findListener(containerID string, pid int) (path string, ok bool) { 68 | pids, ok := m.l[containerID] 69 | if !ok { 70 | return 71 | } 72 | 73 | path, ok = pids[pid] 74 | 75 | return 76 | } 77 | 78 | func (m *InodesMap) nameFor(containerID string, pid int) (name string, ok bool) { 79 | names, ok := m.n[containerID] 80 | if !ok { 81 | return 82 | } 83 | 84 | name, ok = names[pid] 85 | 86 | return 87 | } 88 | 89 | func (m *InodesMap) MarkUnknown(containerID string, pid int, inode uint64) { 90 | if m.u == nil { 91 | m.u = make(map[string]map[int]set.Unordered[uint64]) 92 | } 93 | 94 | pids, ok := m.u[containerID] 95 | if !ok { 96 | pids = make(map[int]set.Unordered[uint64]) 97 | m.u[containerID] = pids 98 | } 99 | 100 | inodes, ok := pids[pid] 101 | if !ok { 102 | inodes = make(set.Unordered[uint64]) 103 | pids[pid] = inodes 104 | } 105 | 106 | inodes.Add(inode) 107 | } 108 | 109 | func (m *InodesMap) ResolveUnknown( 110 | cb func(srcCID, dstCID, srcName, dstName, path string), 111 | ) { 112 | index := make(map[uint64]*item) 113 | 114 | for c, pids := range m.m { 115 | for p, inodes := range pids { 116 | inodes.Iter(func(k uint64) bool { 117 | index[k] = &item{ 118 | Cid: c, 119 | Pid: p, 120 | } 121 | 122 | return true 123 | }) 124 | } 125 | } 126 | 127 | for dstCID, dstPids := range m.u { 128 | for dstPID, inodes := range dstPids { 129 | inodes.Iter(func(k uint64) bool { 130 | known, ok := index[k] 131 | if !ok { 132 | return true 133 | } 134 | 135 | path, ok := m.findListener(known.Cid, known.Pid) 136 | if !ok { 137 | return true 138 | } 139 | 140 | srcName, ok := m.nameFor(known.Cid, known.Pid) 141 | if !ok { 142 | return true 143 | } 144 | 145 | dstName, ok := m.nameFor(dstCID, dstPID) 146 | if !ok { 147 | return true 148 | } 149 | 150 | cb(known.Cid, dstCID, srcName, dstName, path) 151 | 152 | return true 153 | }) 154 | } 155 | } 156 | } 157 | 158 | func (m *InodesMap) Has(containerID string, pid int, inode uint64) (yes bool) { 159 | if m.m == nil { 160 | return false 161 | } 162 | 163 | pids, ok := m.m[containerID] 164 | if !ok { 165 | return false 166 | } 167 | 168 | inodes, ok := pids[pid] 169 | if !ok { 170 | return false 171 | } 172 | 173 | return inodes.Has(inode) 174 | } 175 | -------------------------------------------------------------------------------- /internal/client/inodes_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/client" 7 | ) 8 | 9 | func TestInodes(t *testing.T) { 10 | t.Parallel() 11 | 12 | m := &client.InodesMap{} 13 | 14 | if m.Has("1", 1, 1) { 15 | t.Fail() 16 | } 17 | 18 | // listener 19 | m.AddProcess("1", 1, "app1") 20 | m.AddInode("1", 1, 101) 21 | 22 | // client 23 | m.AddProcess("2", 2, "app2") 24 | 25 | // inodes 26 | m.MarkUnknown("2", 2, 101) 27 | m.MarkListener("1", 1, "/some/sock") 28 | 29 | m.ResolveUnknown(func(srcCID, dstCID, srcName, dstName, path string) { 30 | if srcCID != "1" || dstCID != "2" || srcName != "app1" || dstName != "app2" || path != "/some/sock" { 31 | t.Fail() 32 | } 33 | }) 34 | 35 | if !m.Has("1", 1, 101) { 36 | t.Fail() 37 | } 38 | 39 | if m.Has("1", 2, 1) || m.Has("3", 1, 1) { 40 | t.Fail() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/client/mode.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | type mode byte 4 | 5 | const ( 6 | None mode = 0 7 | InContainer mode = 1 8 | LinuxNsenter mode = 2 9 | ) 10 | 11 | func (m mode) String() (rv string) { 12 | switch m { 13 | case InContainer: 14 | return "in-container" 15 | case LinuxNsenter: 16 | return "linux-nsenter" 17 | case None: 18 | } 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /internal/client/mode_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/client" 7 | ) 8 | 9 | func TestModeNone(t *testing.T) { 10 | t.Parallel() 11 | 12 | if client.None.String() != "" { 13 | t.Fail() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/client/options.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | type Option func(*options) 4 | 5 | type options struct { 6 | Create createClient 7 | Nsenter nsenter 8 | Inodes inodes 9 | Mode mode 10 | } 11 | 12 | func WithMode(m mode) Option { 13 | return func(o *options) { 14 | o.Mode = m 15 | } 16 | } 17 | 18 | func WithClientCreator(c createClient) Option { 19 | return func(o *options) { 20 | o.Create = c 21 | } 22 | } 23 | 24 | func WithNsenterFn(f nsenter) Option { 25 | return func(o *options) { 26 | o.Nsenter = f 27 | } 28 | } 29 | 30 | func WithInodesFn(f inodes) Option { 31 | return func(o *options) { 32 | o.Inodes = f 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/cluster/graph.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/s0rg/set" 7 | 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | const clusterPorts = "[cluster]" 12 | 13 | type connGraph map[string]*Node 14 | 15 | func (g connGraph) upsert(id string) (gn *Node) { 16 | var ok bool 17 | 18 | if gn, ok = g[id]; !ok { 19 | gn = &Node{ 20 | Outbounds: make(set.Unordered[string]), 21 | Inbounds: make(set.Unordered[string]), 22 | Ports: &node.Ports{}, 23 | } 24 | 25 | g[id] = gn 26 | } 27 | 28 | return gn 29 | } 30 | 31 | func (g connGraph) AddNode(n *node.Node) { 32 | gn := g.upsert(n.ID) 33 | 34 | gn.Ports.Join(n.Ports) 35 | } 36 | 37 | func (g connGraph) AddEdge(src, dst string) { 38 | g.upsert(src).Outbounds.Add(dst) 39 | g.upsert(dst).Inbounds.Add(src) 40 | } 41 | 42 | func (g connGraph) NextLayer( 43 | from []string, 44 | seen set.Unordered[string], 45 | ) (rv []string) { 46 | if len(from) == 0 { 47 | for k, n := range g { 48 | switch { 49 | case n.Inbounds.Len() > 0: 50 | case n.Ports.Len() == 0: 51 | default: 52 | if seen.Add(k) { 53 | rv = append(rv, k) 54 | } 55 | } 56 | } 57 | } else { 58 | set.Load(seen, from...) 59 | 60 | for _, src := range from { 61 | g[src].Outbounds.Iter(func(v string) bool { 62 | if seen.Add(v) { 63 | rv = append(rv, v) 64 | } 65 | 66 | return true 67 | }) 68 | } 69 | } 70 | 71 | slices.Sort(rv) 72 | 73 | return rv 74 | } 75 | -------------------------------------------------------------------------------- /internal/cluster/groups.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "cmp" 5 | "slices" 6 | ) 7 | 8 | type match struct { 9 | Matcher *Node 10 | Weight float64 11 | } 12 | 13 | type NodeGrouper struct { 14 | groups map[*Node][]string 15 | matchers []*Node 16 | similarity float64 17 | } 18 | 19 | func NewGrouper( 20 | similarity float64, 21 | ) *NodeGrouper { 22 | return &NodeGrouper{ 23 | similarity: similarity, 24 | matchers: []*Node{}, 25 | groups: make(map[*Node][]string), 26 | } 27 | } 28 | 29 | func (ng *NodeGrouper) Add(k string, n *Node) { 30 | matches := make([]match, 0, len(ng.matchers)) 31 | 32 | for _, m := range ng.matchers { 33 | if w := m.Match(k, n); w >= ng.similarity { 34 | matches = append(matches, match{ 35 | Matcher: m, 36 | Weight: w, 37 | }) 38 | } 39 | } 40 | 41 | var ( 42 | best *Node 43 | found = true 44 | ) 45 | 46 | switch len(matches) { 47 | case 0: 48 | best, found = n.Clone(), false 49 | ng.matchers = append(ng.matchers, best) 50 | case 1: 51 | best = matches[0].Matcher 52 | default: 53 | best = slices.MaxFunc(matches, func(a, b match) int { 54 | return cmp.Compare(a.Weight, b.Weight) 55 | }).Matcher 56 | } 57 | 58 | if found { 59 | best.Merge(n) 60 | } 61 | 62 | ng.groups[best] = append(ng.groups[best], k) 63 | } 64 | 65 | func (ng *NodeGrouper) IterGroups( 66 | iter func(int, []string), 67 | ) { 68 | for id, m := range ng.matchers { 69 | if l := ng.groups[m]; len(l) > 0 { 70 | iter(id, l) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/cluster/groups_test.go: -------------------------------------------------------------------------------- 1 | package cluster_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/cluster" 7 | "github.com/s0rg/decompose/internal/node" 8 | "github.com/s0rg/set" 9 | ) 10 | 11 | func makeTestPorts(vals []*node.Port) (rv *node.Ports) { 12 | rv = &node.Ports{} 13 | 14 | for _, p := range vals { 15 | rv.Add("", p) 16 | } 17 | 18 | return rv 19 | } 20 | 21 | func TestAdd(t *testing.T) { 22 | t.Parallel() 23 | 24 | g := cluster.NewGrouper(0.5) 25 | 26 | g.Add("1", &cluster.Node{ 27 | Inbounds: make(set.Unordered[string]), 28 | Outbounds: make(set.Unordered[string]), 29 | Ports: makeTestPorts([]*node.Port{ 30 | {Kind: "tcp", Value: "1", Number: 1}, 31 | {Kind: "tcp", Value: "2", Number: 2}, 32 | {Kind: "tcp", Value: "3", Number: 3}, 33 | }), 34 | }) 35 | 36 | g.Add("11", &cluster.Node{ 37 | Inbounds: make(set.Unordered[string]), 38 | Outbounds: make(set.Unordered[string]), 39 | Ports: makeTestPorts([]*node.Port{ 40 | {Kind: "tcp", Value: "1", Number: 1}, 41 | {Kind: "tcp", Value: "2", Number: 2}, 42 | {Kind: "tcp", Value: "3", Number: 3}, 43 | }), 44 | }) 45 | 46 | g.Add("2", &cluster.Node{ 47 | Inbounds: make(set.Unordered[string]), 48 | Outbounds: make(set.Unordered[string]), 49 | Ports: makeTestPorts([]*node.Port{ 50 | {Kind: "tcp", Value: "3", Number: 3}, 51 | {Kind: "tcp", Value: "2", Number: 2}, 52 | }), 53 | }) 54 | 55 | g.Add("3", &cluster.Node{ 56 | Inbounds: make(set.Unordered[string]), 57 | Outbounds: make(set.Unordered[string]), 58 | Ports: makeTestPorts([]*node.Port{ 59 | {Kind: "tcp", Value: "1", Number: 1}, 60 | {Kind: "tcp", Value: "3", Number: 3}, 61 | }), 62 | }) 63 | 64 | g.Add("4", &cluster.Node{ 65 | Inbounds: make(set.Unordered[string]), 66 | Outbounds: make(set.Unordered[string]), 67 | Ports: makeTestPorts([]*node.Port{ 68 | {Kind: "tcp", Value: "1", Number: 1}, 69 | {Kind: "tcp", Value: "2", Number: 2}, 70 | }), 71 | }) 72 | 73 | g.Add("5", &cluster.Node{ 74 | Inbounds: make(set.Unordered[string]), 75 | Outbounds: make(set.Unordered[string]), 76 | Ports: makeTestPorts([]*node.Port{ 77 | {Kind: "tcp", Value: "1", Number: 1}, 78 | {Kind: "tcp", Value: "2", Number: 2}, 79 | {Kind: "tcp", Value: "4", Number: 4}, 80 | }), 81 | }) 82 | 83 | g.Add("6", &cluster.Node{ 84 | Inbounds: make(set.Unordered[string]), 85 | Outbounds: make(set.Unordered[string]), 86 | Ports: makeTestPorts([]*node.Port{ 87 | {Kind: "tcp", Value: "1", Number: 1}, 88 | }), 89 | }) 90 | 91 | var groups int 92 | 93 | g.IterGroups(func(_ int, _ []string) { 94 | groups++ 95 | }) 96 | 97 | if groups != 2 { 98 | t.Log(groups) 99 | t.Fail() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/cluster/layers.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/s0rg/set" 10 | "github.com/s0rg/trie" 11 | 12 | "github.com/s0rg/decompose/internal/graph" 13 | "github.com/s0rg/decompose/internal/node" 14 | ) 15 | 16 | type Layers struct { 17 | b graph.NamedBuilderWriter 18 | edges map[string]map[string]*node.Ports 19 | nodes map[string]*node.Node 20 | remotes set.Unordered[string] 21 | g connGraph 22 | defaultName string 23 | similarity float64 24 | } 25 | 26 | func NewLayers( 27 | b graph.NamedBuilderWriter, 28 | s float64, 29 | d string, 30 | ) *Layers { 31 | return &Layers{ 32 | b: NewRules(b, nil), 33 | g: make(connGraph), 34 | edges: make(map[string]map[string]*node.Ports), 35 | nodes: make(map[string]*node.Node), 36 | remotes: make(set.Unordered[string]), 37 | similarity: s, 38 | defaultName: d, 39 | } 40 | } 41 | 42 | func (l *Layers) Name() string { 43 | return l.b.Name() + " auto:" + 44 | strconv.FormatFloat(l.similarity, 'f', 3, 64) 45 | } 46 | 47 | func (l *Layers) AddNode(n *node.Node) error { 48 | l.nodes[n.ID] = n 49 | 50 | if n.IsExternal() { 51 | l.remotes.Add(n.ID) 52 | 53 | return nil 54 | } 55 | 56 | l.g.AddNode(n) 57 | 58 | return nil 59 | } 60 | 61 | func (l *Layers) AddEdge(e *node.Edge) { 62 | l.upsertEdge(e.SrcID, e.DstID, e.Port) 63 | 64 | if l.remotes.Has(e.SrcID) || l.remotes.Has(e.DstID) { 65 | return 66 | } 67 | 68 | l.g.AddEdge(e.SrcID, e.DstID) 69 | } 70 | 71 | func (l *Layers) upsertEdge(src, dst string, p *node.Port) { 72 | dest, ok := l.edges[src] 73 | if !ok { 74 | dest = make(map[string]*node.Ports) 75 | } 76 | 77 | var ports *node.Ports 78 | 79 | if ports, ok = dest[dst]; !ok { 80 | ports = &node.Ports{} 81 | dest[dst] = ports 82 | } 83 | 84 | ports.Add(clusterPorts, p) 85 | 86 | l.edges[src] = dest 87 | } 88 | 89 | func (l *Layers) names(ids []string) (rv []string) { 90 | rv = make([]string, 0, len(ids)) 91 | 92 | for _, id := range ids { 93 | n := l.nodes[id] 94 | 95 | rv = append(rv, n.Name) 96 | } 97 | 98 | return rv 99 | } 100 | 101 | func (l *Layers) Write(w io.Writer) error { 102 | var ( 103 | seen = make(set.Unordered[string]) 104 | layer []string 105 | ) 106 | 107 | const maxLabelParts = 3 108 | 109 | for i := 0; ; i++ { 110 | layer = l.g.NextLayer(layer, seen) 111 | if len(layer) == 0 { 112 | break 113 | } 114 | 115 | grp := NewGrouper(l.similarity) 116 | 117 | for _, id := range layer { 118 | grp.Add(id, l.g[id]) 119 | } 120 | 121 | grp.IterGroups(func(id int, membersID []string) { 122 | label := CreateLabel(l.names(membersID), maxLabelParts) 123 | 124 | for _, mid := range membersID { 125 | n := l.nodes[mid] 126 | n.Cluster = fmt.Sprintf("l%02d-%02d-%s", i, id, label) 127 | 128 | _ = l.b.AddNode(n) 129 | 130 | delete(l.nodes, mid) 131 | } 132 | }) 133 | } 134 | 135 | // store remains 136 | for _, n := range l.nodes { 137 | n.Cluster = l.defaultName 138 | _ = l.b.AddNode(n) 139 | } 140 | 141 | for src, dmap := range l.edges { 142 | for dst, ports := range dmap { 143 | ports.Iter(func(_ string, plist []*node.Port) { 144 | for _, p := range plist { 145 | l.b.AddEdge(&node.Edge{ 146 | SrcID: src, 147 | DstID: dst, 148 | SrcName: clusterPorts, 149 | DstName: clusterPorts, 150 | Port: p, 151 | }) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | if err := l.b.Write(w); err != nil { 158 | return fmt.Errorf("auto: %w", err) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | func CreateLabel(names []string, nmax int) (rv string) { 165 | t := trie.New[struct{}]() 166 | v := struct{}{} 167 | 168 | for _, n := range names { 169 | t.Add(n, v) 170 | } 171 | 172 | const ( 173 | root = "" 174 | minus = "-" 175 | cutset = "1234567890" + minus 176 | maxdiff = 3 177 | ) 178 | 179 | comm := t.Common(root, maxdiff) 180 | if len(comm) > nmax { 181 | comm = comm[:nmax] 182 | } 183 | 184 | for i := 0; i < len(comm); i++ { 185 | comm[i] = strings.Trim(comm[i], cutset) 186 | 187 | if k := strings.Index(comm[i], minus); k > 0 { 188 | comm[i] = comm[i][:k] 189 | } 190 | } 191 | 192 | return strings.Join(comm, minus) 193 | } 194 | -------------------------------------------------------------------------------- /internal/cluster/layers_test.go: -------------------------------------------------------------------------------- 1 | package cluster_test 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/s0rg/decompose/internal/cluster" 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | func TestLayers(t *testing.T) { 14 | t.Parallel() 15 | 16 | tb := &testNamedBuilder{clusters: make(map[string][]string)} 17 | 18 | const similarity = 0.6 19 | 20 | ca := cluster.NewLayers(tb, similarity, "foo") 21 | 22 | if !strings.Contains(ca.Name(), strconv.FormatFloat(similarity, 'f', 1, 64)) { 23 | t.Fail() 24 | } 25 | 26 | _ = ca.AddNode(&node.Node{ 27 | ID: "6", 28 | Name: "node-6", 29 | Ports: makeTestPorts([]*node.Port{ 30 | {Kind: "tcp", Value: "6"}, 31 | {Kind: "tcp", Value: "1234"}, 32 | {Kind: "tcp", Value: "8080"}, 33 | }), 34 | }) 35 | 36 | _ = ca.AddNode(&node.Node{ 37 | ID: "1", 38 | Name: "node-1", 39 | Ports: makeTestPorts([]*node.Port{ 40 | {Kind: "tcp", Value: "80"}, 41 | {Kind: "tcp", Value: "443"}, 42 | }), 43 | }) 44 | 45 | _ = ca.AddNode(&node.Node{ 46 | ID: "2", 47 | Name: "node-2", 48 | Ports: makeTestPorts([]*node.Port{ 49 | {Kind: "tcp", Value: "2"}, 50 | {Kind: "tcp", Value: "1234"}, 51 | {Kind: "tcp", Value: "8080"}, 52 | }), 53 | }) 54 | 55 | _ = ca.AddNode(&node.Node{ 56 | ID: "3", 57 | Name: "node-3", 58 | Ports: makeTestPorts([]*node.Port{ 59 | {Kind: "udp", Value: "53"}, 60 | {Kind: "tcp", Value: "8080"}, 61 | }), 62 | }) 63 | 64 | _ = ca.AddNode(&node.Node{ 65 | ID: "4", 66 | Name: "node-4", 67 | Ports: makeTestPorts([]*node.Port{ 68 | {Kind: "tcp", Value: "9090"}, 69 | }), 70 | }) 71 | 72 | _ = ca.AddNode(&node.Node{ 73 | ID: "5", 74 | Name: "node-5", 75 | Ports: makeTestPorts([]*node.Port{ 76 | {Kind: "tcp", Value: "1234"}, 77 | {Kind: "tcp", Value: "8081"}, 78 | }), 79 | }) 80 | 81 | _ = ca.AddNode(&node.Node{ 82 | ID: "R", 83 | Name: "R", 84 | Ports: makeTestPorts([]*node.Port{ 85 | {Kind: "tcp", Value: "22"}, 86 | }), 87 | }) 88 | 89 | _ = ca.AddNode(&node.Node{ 90 | ID: "5", 91 | Name: "node-5", 92 | Ports: makeTestPorts([]*node.Port{ 93 | {Kind: "tcp", Value: "1234"}, 94 | {Kind: "tcp", Value: "8081"}, 95 | }), 96 | }) 97 | 98 | _ = ca.AddNode(&node.Node{ 99 | ID: "10", 100 | Name: "node-10", 101 | Ports: &node.Ports{}, 102 | }) 103 | 104 | ca.AddEdge(&node.Edge{ 105 | SrcID: "1", 106 | DstID: "2", 107 | Port: &node.Port{Kind: "tcp", Value: "1234"}, 108 | }) 109 | 110 | ca.AddEdge(&node.Edge{ 111 | SrcID: "1", 112 | DstID: "3", 113 | Port: &node.Port{Kind: "tcp", Value: "8080"}, 114 | }) 115 | 116 | ca.AddEdge(&node.Edge{ 117 | SrcID: "1", 118 | DstID: "6", 119 | Port: &node.Port{Kind: "tcp", Value: "8080"}, 120 | }) 121 | 122 | ca.AddEdge(&node.Edge{ 123 | SrcID: "2", 124 | DstID: "4", 125 | Port: &node.Port{Kind: "tcp", Value: "9090"}, 126 | }) 127 | 128 | ca.AddEdge(&node.Edge{ 129 | SrcID: "3", 130 | DstID: "2", 131 | Port: &node.Port{Kind: "tcp", Value: "9090"}, 132 | }) 133 | 134 | ca.AddEdge(&node.Edge{ 135 | SrcID: "4", 136 | DstID: "3", 137 | Port: &node.Port{Kind: "tcp", Value: "8080"}, 138 | }) 139 | 140 | ca.AddEdge(&node.Edge{ 141 | SrcID: "1", 142 | DstID: "5", 143 | Port: &node.Port{Kind: "tcp", Value: "8081"}, 144 | }) 145 | 146 | ca.AddEdge(&node.Edge{ 147 | SrcID: "5", 148 | DstID: "4", 149 | Port: &node.Port{Kind: "tcp", Value: "9090"}, 150 | }) 151 | 152 | ca.AddEdge(&node.Edge{ 153 | SrcID: "5", 154 | DstID: "R", 155 | Port: &node.Port{Kind: "tcp", Value: "22"}, 156 | }) 157 | 158 | if err := ca.Write(nil); err != nil { 159 | t.Fatal(err) 160 | } 161 | 162 | const ( 163 | edgesDirect = 9 164 | edgesCluster = 8 165 | 166 | wantNodes = 8 167 | wantEdges = edgesDirect + edgesCluster 168 | wantClusters = 6 169 | ) 170 | 171 | if tb.Nodes != wantNodes || tb.Edges != wantEdges { 172 | t.Log("nodes:", tb.Nodes, "want:", wantNodes, "edges:", tb.Edges, "want:", wantEdges) 173 | t.Fail() 174 | } 175 | 176 | if tb.Clusters() != wantClusters { 177 | t.Log("clusters:", tb.Clusters(), "want:", wantClusters) 178 | t.Fail() 179 | } 180 | } 181 | 182 | func TestLayersWriteError(t *testing.T) { 183 | t.Parallel() 184 | 185 | tb := &testNamedBuilder{ 186 | Err: errors.New("test-error"), 187 | } 188 | 189 | const similarity = 0.5 190 | 191 | ca := cluster.NewLayers(tb, similarity, "") 192 | 193 | if err := ca.Write(nil); !errors.Is(err, tb.Err) { 194 | t.Fail() 195 | } 196 | } 197 | 198 | func TestLayersLabel(t *testing.T) { 199 | t.Parallel() 200 | 201 | s := []string{ 202 | "foo-one1", 203 | "foo-two-1", 204 | "bar1", 205 | "bar-two-2", 206 | "barista", 207 | "doo", 208 | "doo2", 209 | "foo-1", 210 | "too-tee-1", 211 | "too-tee-2", 212 | "too-tee-3", 213 | "too-tee-4", 214 | } 215 | 216 | const ( 217 | maxParts = 2 218 | want1 = "too-foo" 219 | want2 = "too-bar" 220 | ) 221 | 222 | switch l := cluster.CreateLabel(s, 2); l { 223 | case want1, want2: 224 | default: 225 | t.Log(l) 226 | t.Fail() 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /internal/cluster/node.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "math" 5 | "slices" 6 | 7 | "github.com/s0rg/set" 8 | 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | type Node struct { 13 | Inbounds set.Set[string] 14 | Outbounds set.Set[string] 15 | Ports *node.Ports 16 | } 17 | 18 | func (n *Node) Clone() *Node { 19 | return &Node{ 20 | Inbounds: n.Inbounds.Clone(), 21 | Outbounds: n.Outbounds.Clone(), 22 | Ports: n.Ports, 23 | } 24 | } 25 | 26 | const ( 27 | onei = 1 28 | onef = 1.0 29 | half = 0.5 30 | ) 31 | 32 | func (n *Node) Match(id string, o *Node) (rv float64) { 33 | rv = (n.matchConns(id) + n.matchPorts(o.Ports)) * half 34 | 35 | return rv 36 | } 37 | 38 | func (n *Node) Merge(o *Node) { 39 | n.Ports.Join(o.Ports) 40 | n.Ports.Compact() 41 | 42 | n.Inbounds = set.Union(n.Inbounds, o.Inbounds) 43 | n.Outbounds = set.Union(n.Outbounds, o.Outbounds) 44 | } 45 | 46 | func (n *Node) matchConns(id string) (rv float64) { 47 | if n.Inbounds.Has(id) { 48 | rv += half 49 | } 50 | 51 | if n.Outbounds.Has(id) { 52 | rv += half 53 | } 54 | 55 | return rv 56 | } 57 | 58 | func (n *Node) matchPorts(p *node.Ports) (rv float64) { 59 | var ( 60 | a = portsToProtos(n.Ports) 61 | b = portsToProtos(p) 62 | ) 63 | 64 | if len(a) > len(b) { 65 | a, b = b, a 66 | } 67 | 68 | for k, ap := range a { 69 | bp := b[k] 70 | 71 | rv += matchSlices(ap, bp) / float64(len(a)) 72 | } 73 | 74 | return rv 75 | } 76 | 77 | func portsToProtos(ports *node.Ports) (rv map[string][]int) { 78 | rv = make(map[string][]int) 79 | 80 | ports.Iter(func(_ string, pl []*node.Port) { 81 | for _, p := range pl { 82 | if p.Kind == "unix" { 83 | continue 84 | } 85 | 86 | rv[p.Kind] = append(rv[p.Kind], p.Number) 87 | } 88 | }) 89 | 90 | for k := range rv { 91 | slices.Sort(rv[k]) 92 | } 93 | 94 | return rv 95 | } 96 | 97 | func matchSlices(a, b []int) (rv float64) { 98 | sa, sb := make(set.Unordered[int]), make(set.Unordered[int]) 99 | 100 | if len(a) < len(b) { 101 | a, b = b, a 102 | } 103 | 104 | set.Load(sa, a...) 105 | set.Load(sb, b...) 106 | 107 | u := set.Union(sa, sb).Len() 108 | c := set.Intersect(sa, sb).Len() 109 | 110 | if u == c { 111 | return onef 112 | } 113 | 114 | da, db := set.Diff(sa, sb), set.Diff(sb, sa) 115 | 116 | if da.Len() < db.Len() { 117 | da, db = db, da 118 | } 119 | 120 | rv = float64(da.Len()) / math.Abs(float64(u)-float64(c)) 121 | 122 | das, dab := set.ToSlice(da), set.ToSlice(db) 123 | 124 | slices.Sort(das) 125 | slices.Sort(dab) 126 | 127 | m := float64(db.Len()) / float64(u) 128 | 129 | for i := 0; i < len(das); i++ { 130 | for j := 0; j < len(dab); j++ { 131 | if abs(das[i]-dab[j]) == onei { 132 | rv += m 133 | } 134 | } 135 | } 136 | 137 | return rv 138 | } 139 | 140 | func abs(v int) int { 141 | if v < 0 { 142 | v = -v 143 | } 144 | 145 | return v 146 | } 147 | -------------------------------------------------------------------------------- /internal/cluster/node_test.go: -------------------------------------------------------------------------------- 1 | package cluster_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/set" 7 | 8 | "github.com/s0rg/decompose/internal/cluster" 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | func TestNodeMatch(t *testing.T) { 13 | t.Parallel() 14 | 15 | a := &cluster.Node{ 16 | Inbounds: make(set.Unordered[string]), 17 | Outbounds: make(set.Unordered[string]), 18 | Ports: &node.Ports{}, 19 | } 20 | 21 | b := &cluster.Node{ 22 | Inbounds: make(set.Unordered[string]), 23 | Outbounds: make(set.Unordered[string]), 24 | Ports: &node.Ports{}, 25 | } 26 | 27 | a.Inbounds.Add("1") 28 | a.Outbounds.Add("2") 29 | 30 | if a.Match("0", b) > 0.0 { 31 | t.FailNow() 32 | } 33 | 34 | if a.Match("1", b) == 0.0 { 35 | t.FailNow() 36 | } 37 | 38 | if a.Match("2", b) == 0.0 { 39 | t.FailNow() 40 | } 41 | } 42 | 43 | func TestNodeMatchPorts(t *testing.T) { 44 | t.Parallel() 45 | 46 | a := &cluster.Node{ 47 | Inbounds: make(set.Unordered[string]), 48 | Outbounds: make(set.Unordered[string]), 49 | Ports: &node.Ports{}, 50 | } 51 | 52 | a.Ports.Add("", &node.Port{Kind: "tcp", Value: "1"}) 53 | 54 | b := &cluster.Node{ 55 | Inbounds: make(set.Unordered[string]), 56 | Outbounds: make(set.Unordered[string]), 57 | Ports: &node.Ports{}, 58 | } 59 | 60 | b.Ports.Add("", &node.Port{Kind: "tcp", Value: "1"}) 61 | b.Ports.Add("", &node.Port{Kind: "tcp", Value: "5"}) 62 | 63 | a.Inbounds.Add("1") 64 | a.Outbounds.Add("2") 65 | 66 | if a.Match("", b) != 0.5 { 67 | t.Fail() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/cluster/rules.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "slices" 9 | 10 | "github.com/expr-lang/expr" 11 | "github.com/expr-lang/expr/vm" 12 | 13 | "github.com/s0rg/decompose/internal/graph" 14 | "github.com/s0rg/decompose/internal/node" 15 | ) 16 | 17 | type ( 18 | ruleJSON struct { 19 | Name string `json:"name"` 20 | Expr string `json:"if"` 21 | Weight int `json:"weight"` 22 | } 23 | 24 | rulePROG struct { 25 | Prog *vm.Program 26 | Name string 27 | Weight int 28 | } 29 | 30 | ruleENV struct { 31 | Node *node.View `expr:"node"` 32 | } 33 | 34 | exprRUN func(*vm.Program, any) (any, error) 35 | 36 | Rules struct { 37 | builder graph.NamedBuilderWriter 38 | runner exprRUN 39 | nodes map[string]*node.Node 40 | cluster map[string]map[string]*node.Ports 41 | rules []*rulePROG 42 | } 43 | ) 44 | 45 | func NewRules( 46 | b graph.NamedBuilderWriter, 47 | r exprRUN, 48 | ) *Rules { 49 | if r == nil { 50 | r = expr.Run 51 | } 52 | 53 | return &Rules{ 54 | builder: b, 55 | runner: r, 56 | nodes: make(map[string]*node.Node), 57 | cluster: make(map[string]map[string]*node.Ports), 58 | } 59 | } 60 | 61 | func (cb *Rules) Name() string { 62 | return cb.builder.Name() + " clustered" 63 | } 64 | 65 | func (cb *Rules) Write(w io.Writer) error { 66 | for src, dmap := range cb.cluster { 67 | for dst, ports := range dmap { 68 | ports.Iter(func(_ string, plist []*node.Port) { 69 | for _, p := range plist { 70 | cb.builder.AddEdge(&node.Edge{ 71 | SrcID: src, 72 | DstID: dst, 73 | SrcName: clusterPorts, 74 | DstName: clusterPorts, 75 | Port: p, 76 | }) 77 | } 78 | }) 79 | } 80 | } 81 | 82 | if err := cb.builder.Write(w); err != nil { 83 | return fmt.Errorf("%w", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (cb *Rules) AddNode(n *node.Node) error { 90 | if cluster, ok := cb.Match(n); ok { 91 | n.Cluster = cluster 92 | } 93 | 94 | cb.nodes[n.ID] = n 95 | 96 | if err := cb.builder.AddNode(n); err != nil { 97 | return fmt.Errorf("builder: %w", err) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (cb *Rules) AddEdge(e *node.Edge) { 104 | nsrc, ok := cb.nodes[e.SrcID] 105 | if !ok { 106 | return 107 | } 108 | 109 | ndst, ok := cb.nodes[e.DstID] 110 | if !ok { 111 | return 112 | } 113 | 114 | if nsrc.Cluster != ndst.Cluster { 115 | cdst, ok := cb.cluster[nsrc.Cluster] 116 | if !ok { 117 | cdst = make(map[string]*node.Ports) 118 | } 119 | 120 | var ports *node.Ports 121 | 122 | if ports, ok = cdst[ndst.Cluster]; !ok { 123 | ports = &node.Ports{} 124 | cdst[ndst.Cluster] = ports 125 | } 126 | 127 | ports.Add(clusterPorts, e.Port) 128 | 129 | cb.cluster[nsrc.Cluster] = cdst 130 | } 131 | 132 | cb.builder.AddEdge(e) 133 | } 134 | 135 | func (cb *Rules) CountRules() int { 136 | return len(cb.rules) 137 | } 138 | 139 | func (cb *Rules) FromReader(r io.Reader) (err error) { 140 | var rules []ruleJSON 141 | 142 | dec := json.NewDecoder(r) 143 | 144 | for dec.More() { 145 | if err = dec.Decode(&rules); err != nil { 146 | return fmt.Errorf("decode: %w", err) 147 | } 148 | } 149 | 150 | opts := []expr.Option{ 151 | expr.Env(ruleENV{}), 152 | expr.Optimize(true), 153 | expr.AsBool(), 154 | } 155 | 156 | for i := 0; i < len(rules); i++ { 157 | rule := &rules[i] 158 | 159 | prog, cerr := expr.Compile(rule.Expr, opts...) 160 | if cerr != nil { 161 | return fmt.Errorf("compile '%s': %w", rule.Expr, cerr) 162 | } 163 | 164 | cb.rules = append(cb.rules, &rulePROG{ 165 | Name: rule.Name, 166 | Weight: max(rule.Weight, 1), 167 | Prog: prog, 168 | }) 169 | } 170 | 171 | slices.SortFunc(cb.rules, func(a, b *rulePROG) int { 172 | return cmp.Compare(b.Weight, a.Weight) 173 | }) 174 | 175 | return nil 176 | } 177 | 178 | func (cb *Rules) Match(n *node.Node) (cluster string, ok bool) { 179 | if len(cb.rules) == 0 { 180 | return 181 | } 182 | 183 | for _, rule := range cb.rules { 184 | res, err := cb.runner(rule.Prog, ruleENV{Node: n.ToView()}) 185 | if err != nil { 186 | continue 187 | } 188 | 189 | resb, ok := res.(bool) 190 | if !ok || !resb { 191 | continue 192 | } 193 | 194 | return rule.Name, true 195 | } 196 | 197 | return "", false 198 | } 199 | -------------------------------------------------------------------------------- /internal/cluster/rules_test.go: -------------------------------------------------------------------------------- 1 | package cluster_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/expr-lang/expr/vm" 11 | 12 | "github.com/s0rg/decompose/internal/cluster" 13 | "github.com/s0rg/decompose/internal/node" 14 | ) 15 | 16 | const ( 17 | testBuilderName = "testbuilder" 18 | clusterRules = `[{"name": "foo", "if": "node.Listen.Has('tcp:80')"}, 19 | {"name": "bar", "if": "node.Listen.HasAny('tcp:22', 'tcp:443')"}]` 20 | ) 21 | 22 | type testNamedBuilder struct { 23 | clusters map[string][]string 24 | Err error 25 | Nodes int 26 | Edges int 27 | } 28 | 29 | func (tb *testNamedBuilder) AddNode(n *node.Node) error { 30 | if tb.Err != nil { 31 | return tb.Err 32 | } 33 | 34 | tb.Nodes++ 35 | 36 | if tb.clusters != nil { 37 | tb.clusters[n.Cluster] = append(tb.clusters[n.Cluster], n.Name) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (tb *testNamedBuilder) AddEdge(_ *node.Edge) { 44 | tb.Edges++ 45 | } 46 | 47 | func (tb *testNamedBuilder) Name() string { return testBuilderName } 48 | func (tb *testNamedBuilder) Write(_ io.Writer) error { return tb.Err } 49 | 50 | func (tb *testNamedBuilder) Clusters() int { 51 | return len(tb.clusters) 52 | } 53 | 54 | func TestClusterError(t *testing.T) { 55 | t.Parallel() 56 | 57 | testCases := []string{ 58 | `{`, 59 | `[{"name": "foo", "if": ""}]`, 60 | `[{"name": "foo", "if": "#"}]`, 61 | } 62 | 63 | ca := cluster.NewRules(nil, nil) 64 | 65 | for _, tc := range testCases { 66 | if err := ca.FromReader(bytes.NewBufferString(tc)); err == nil { 67 | t.Fail() 68 | } 69 | } 70 | } 71 | 72 | func TestRulesMatch(t *testing.T) { 73 | t.Parallel() 74 | 75 | testCases := []struct { 76 | Node *node.Node 77 | Want string 78 | }{ 79 | { 80 | Node: &node.Node{ 81 | Ports: makeTestPorts([]*node.Port{{Kind: "tcp", Value: "80", Number: 80}}), 82 | }, 83 | Want: "foo", 84 | }, 85 | { 86 | Node: &node.Node{ 87 | Ports: makeTestPorts([]*node.Port{{Kind: "tcp", Value: "22", Number: 22}}), 88 | }, 89 | Want: "bar", 90 | }, 91 | { 92 | Node: &node.Node{ 93 | Ports: makeTestPorts([]*node.Port{{Kind: "tcp", Value: "443", Number: 443}}), 94 | }, 95 | Want: "bar", 96 | }, 97 | { 98 | Node: &node.Node{Ports: makeTestPorts([]*node.Port{ 99 | {Kind: "tcp", Value: "22", Number: 22}, 100 | {Kind: "tcp", Value: "80", Number: 80}, 101 | {Kind: "tcp", Value: "443", Number: 443}, 102 | })}, 103 | Want: "foo", 104 | }, 105 | { 106 | Node: &node.Node{Ports: makeTestPorts([]*node.Port{ 107 | {Kind: "tcp", Value: "22", Number: 22}, 108 | {Kind: "tcp", Value: "80", Number: 80}, 109 | {Kind: "tcp", Value: "8080", Number: 8080}, 110 | })}, 111 | Want: "foo", 112 | }, 113 | { 114 | Node: &node.Node{Ports: makeTestPorts([]*node.Port{ 115 | {Kind: "sstp", Value: "5000"}, 116 | })}, 117 | Want: "", 118 | }, 119 | } 120 | 121 | ca := cluster.NewRules(nil, nil) 122 | 123 | n := testCases[0].Node 124 | 125 | if _, ok := ca.Match(n); ok { 126 | t.Fail() 127 | } 128 | 129 | if ca.CountRules() != 0 { 130 | t.Fail() 131 | } 132 | 133 | if err := ca.FromReader(bytes.NewBufferString(clusterRules)); err != nil { 134 | t.Fatal(err) 135 | } 136 | 137 | if ca.CountRules() != 2 { 138 | t.Fail() 139 | } 140 | 141 | for _, tc := range testCases { 142 | m, ok := ca.Match(tc.Node) 143 | if tc.Want != "" && !ok { 144 | t.Fail() 145 | } 146 | 147 | if m != tc.Want { 148 | t.Fail() 149 | } 150 | } 151 | } 152 | 153 | func TestRulesMatchWeight(t *testing.T) { 154 | t.Parallel() 155 | 156 | const clusterRulesWeight = `[{"name": "foo", "weight": 2, "if": "node.Listen.Has('tcp:80')"}, 157 | {"name": "bar", "if": "node.Listen.HasAny('tcp:22', 'tcp:443')"}]` 158 | 159 | testNode := &node.Node{Ports: makeTestPorts([]*node.Port{ 160 | {Kind: "tcp", Value: "22", Number: 22}, 161 | {Kind: "tcp", Value: "80", Number: 80}, 162 | {Kind: "tcp", Value: "8080", Number: 8080}, 163 | })} 164 | 165 | ca := cluster.NewRules(nil, nil) 166 | 167 | if err := ca.FromReader(bytes.NewBufferString(clusterRulesWeight)); err != nil { 168 | t.Fatal(err) 169 | } 170 | 171 | m, ok := ca.Match(testNode) 172 | if !ok { 173 | t.Fail() 174 | } 175 | 176 | if m != "foo" { 177 | t.Fail() 178 | } 179 | } 180 | 181 | func TestRules(t *testing.T) { 182 | t.Parallel() 183 | 184 | tb := &testNamedBuilder{} 185 | ca := cluster.NewRules(tb, nil) 186 | 187 | if err := ca.FromReader(bytes.NewBufferString(clusterRules)); err != nil { 188 | t.Fatal(err) 189 | } 190 | 191 | ca.AddNode(&node.Node{ 192 | ID: "1", 193 | Ports: makeTestPorts([]*node.Port{ 194 | {Kind: "tcp", Value: "80", Number: 80}, 195 | })}) 196 | 197 | ca.AddNode(&node.Node{ 198 | ID: "2", 199 | Ports: makeTestPorts([]*node.Port{ 200 | {Kind: "tcp", Value: "22", Number: 22}, 201 | })}) 202 | 203 | ca.AddNode(&node.Node{ 204 | ID: "3", 205 | Ports: makeTestPorts([]*node.Port{ 206 | {Kind: "tcp", Value: "443", Number: 443}, 207 | {Kind: "tcp", Value: "8080", Number: 8080}, 208 | })}) 209 | 210 | ca.AddNode(&node.Node{ 211 | ID: "4", 212 | Ports: makeTestPorts([]*node.Port{ 213 | {Kind: "tcp", Value: "8080", Number: 8080}, 214 | })}) 215 | 216 | ca.AddEdge(&node.Edge{ 217 | SrcID: "1", 218 | DstID: "3", 219 | Port: &node.Port{Kind: "tcp", Value: "443", Number: 443}, 220 | }) 221 | 222 | ca.AddEdge(&node.Edge{ 223 | SrcID: "3", 224 | DstID: "1", 225 | Port: &node.Port{Kind: "tcp", Value: "8080", Number: 8080}, 226 | }) 227 | 228 | ca.AddEdge(&node.Edge{ 229 | SrcID: "3", 230 | DstID: "1", 231 | Port: &node.Port{Kind: "tcp", Value: "80", Number: 80}, 232 | }) 233 | 234 | ca.AddEdge(&node.Edge{ 235 | SrcID: "1", 236 | DstID: "4", 237 | Port: &node.Port{Kind: "tcp", Value: "8080", Number: 8080}, 238 | }) 239 | 240 | ca.AddEdge(&node.Edge{ 241 | SrcID: "5", 242 | DstID: "1", 243 | Port: &node.Port{Kind: "tcp", Value: "80", Number: 80}, 244 | }) 245 | 246 | ca.AddEdge(&node.Edge{ 247 | SrcID: "1", 248 | DstID: "5", 249 | Port: &node.Port{Kind: "tcp", Value: "80", Number: 80}, 250 | }) 251 | 252 | if tb.Edges != 4 || tb.Nodes != 4 { 253 | t.Fail() 254 | } 255 | 256 | ca.Write(nil) 257 | 258 | if tb.Edges != 8 { 259 | t.Log(tb) 260 | t.Fail() 261 | } 262 | } 263 | 264 | func TestRulesMatchError(t *testing.T) { 265 | t.Parallel() 266 | 267 | myError := errors.New("test-error") 268 | 269 | tb := &testNamedBuilder{} 270 | ca := cluster.NewRules(tb, func(_ *vm.Program, _ any) (any, error) { 271 | return nil, myError 272 | }) 273 | 274 | if err := ca.FromReader(bytes.NewBufferString(clusterRules)); err != nil { 275 | t.Fatal(err) 276 | } 277 | 278 | if _, ok := ca.Match(&node.Node{}); ok { 279 | t.Fail() 280 | } 281 | } 282 | 283 | func TestRulesBuilderAddError(t *testing.T) { 284 | t.Parallel() 285 | 286 | myError := errors.New("test-error") 287 | 288 | tb := &testNamedBuilder{Err: myError} 289 | ca := cluster.NewRules(tb, nil) 290 | 291 | if err := ca.FromReader(bytes.NewBufferString(clusterRules)); err != nil { 292 | t.Fatal(err) 293 | } 294 | 295 | err := ca.AddNode(&node.Node{ 296 | ID: "1", 297 | Ports: makeTestPorts([]*node.Port{ 298 | {Kind: "tcp", Value: "80", Number: 80}, 299 | })}) 300 | if !errors.Is(err, myError) { 301 | t.Fail() 302 | } 303 | } 304 | 305 | func TestRulesBuilderName(t *testing.T) { 306 | t.Parallel() 307 | 308 | tb := &testNamedBuilder{} 309 | ca := cluster.NewRules(tb, nil) 310 | 311 | name := ca.Name() 312 | 313 | if !strings.HasPrefix(name, testBuilderName) { 314 | t.Fail() 315 | } 316 | } 317 | 318 | func TestRulesBuilderWriteError(t *testing.T) { 319 | t.Parallel() 320 | 321 | tb := &testNamedBuilder{} 322 | ca := cluster.NewRules(tb, nil) 323 | 324 | if err := ca.FromReader(bytes.NewBufferString(clusterRules)); err != nil { 325 | t.Fatal(err) 326 | } 327 | 328 | ca.AddNode(&node.Node{ 329 | ID: "1", 330 | Ports: makeTestPorts([]*node.Port{ 331 | {Kind: "tcp", Value: "80", Number: 80}, 332 | })}) 333 | 334 | tb.Err = errors.New("test-error") 335 | 336 | if err := ca.Write(nil); !errors.Is(err, tb.Err) { 337 | t.Fail() 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /internal/graph/build.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | 10 | "github.com/s0rg/decompose/internal/node" 11 | ) 12 | 13 | const ( 14 | minItems = 2 15 | minReport = 10 16 | 17 | ProcessRemote = "[remote]" 18 | ProcessUnknown = "[unknown]" 19 | ) 20 | 21 | var ErrNotEnough = errors.New("not enough items") 22 | 23 | type ContainerClient interface { 24 | Containers(context.Context, NetProto, bool, []string, func(int, int)) ([]*Container, error) 25 | } 26 | 27 | type Builder interface { 28 | AddNode(*node.Node) error 29 | AddEdge(*node.Edge) 30 | } 31 | 32 | type NamedWriter interface { 33 | Name() string 34 | Write(io.Writer) error 35 | } 36 | 37 | type NamedBuilderWriter interface { 38 | Builder 39 | NamedWriter 40 | } 41 | 42 | type Enricher interface { 43 | Enrich(*node.Node) 44 | } 45 | 46 | func Build( 47 | cfg *Config, 48 | cli ContainerClient, 49 | ) error { 50 | log.Println("Gathering containers info, please be patient...") 51 | 52 | containers, err := cli.Containers( 53 | context.Background(), 54 | cfg.Proto, 55 | cfg.Deep, 56 | cfg.SkipEnv, 57 | func(cur, total int) { 58 | switch { 59 | case cur == 0: 60 | return 61 | case cur < total && cur%minReport > 0: 62 | return 63 | } 64 | 65 | log.Printf("Processing %d / %d [%.02f%%]", cur, total, percentOf(cur, total)) 66 | }) 67 | if err != nil { 68 | return fmt.Errorf("containers: %w", err) 69 | } 70 | 71 | log.Printf("Found %d alive containers", len(containers)) 72 | 73 | if len(containers) < minItems { 74 | return fmt.Errorf("%w: containers", ErrNotEnough) 75 | } 76 | 77 | state := newBuilderState(cfg, containers) 78 | 79 | log.Println("Building nodes...") 80 | 81 | nodes, err := state.BuildNodes() 82 | if err != nil { 83 | return fmt.Errorf("build nodes: %w", err) 84 | } 85 | 86 | log.Printf("Processing %d nodes", nodes) 87 | 88 | if nodes < minItems { 89 | return fmt.Errorf("%w: nodes", ErrNotEnough) 90 | } 91 | 92 | log.Println("Building edges...") 93 | 94 | log.Printf("Found %d edges", state.BuildEdges()) 95 | 96 | return nil 97 | } 98 | 99 | func percentOf(a, b int) float64 { 100 | const hundred = 100.0 101 | 102 | return float64(a) / float64(b) * hundred 103 | } 104 | -------------------------------------------------------------------------------- /internal/graph/build_state.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strconv" 7 | 8 | "github.com/s0rg/decompose/internal/node" 9 | "github.com/s0rg/set" 10 | ) 11 | 12 | type builderState struct { 13 | Config *Config 14 | KnownIP map[string]*Container 15 | Nodes map[string]*node.Node 16 | Remotes set.Unordered[string] 17 | Containers []*Container 18 | } 19 | 20 | func newBuilderState( 21 | cfg *Config, 22 | cntrs []*Container, 23 | ) (bs *builderState) { 24 | bs = &builderState{ 25 | Config: cfg, 26 | Containers: cntrs, 27 | KnownIP: make(map[string]*Container, len(cntrs)), 28 | Nodes: make(map[string]*node.Node, len(cntrs)), 29 | Remotes: make(set.Unordered[string]), 30 | } 31 | 32 | for _, it := range cntrs { 33 | for ip := range it.Endpoints { 34 | bs.KnownIP[ip] = it 35 | } 36 | } 37 | 38 | return bs 39 | } 40 | 41 | func (bs *builderState) BuildNodes() (total int, err error) { 42 | var notice bool 43 | 44 | for _, con := range bs.Containers { 45 | if con.ConnectionsCount() == 0 && !notice { 46 | log.Printf("No connections for container: %s:%s, try run as root", con.ID, con.Name) 47 | 48 | notice = true 49 | } 50 | 51 | if !bs.matchContainer(con) { 52 | continue 53 | } 54 | 55 | n := con.ToNode() 56 | 57 | bs.Config.Meta.Enrich(n) 58 | 59 | if err = bs.Config.Builder.AddNode(n); err != nil { 60 | return 0, fmt.Errorf("node '%s': %w", n.Name, err) 61 | } 62 | 63 | bs.Nodes[con.ID] = n 64 | 65 | total++ 66 | } 67 | 68 | if !bs.Config.OnlyLocal { 69 | bs.Remotes.Iter(func(rip string) bool { 70 | n := bs.Nodes[rip] 71 | 72 | if err = bs.Config.Builder.AddNode(n); err != nil { 73 | err = fmt.Errorf("node '%s': %w", n.Name, err) 74 | 75 | return false 76 | } 77 | 78 | total++ 79 | 80 | return true 81 | }) 82 | } 83 | 84 | return total, err 85 | } 86 | 87 | func (bs *builderState) BuildEdges() (total int) { 88 | for _, con := range bs.Containers { 89 | src, ok := bs.Nodes[con.ID] 90 | if !ok { 91 | continue 92 | } 93 | 94 | con.IterOutbounds(func(c *Connection) { 95 | if edge, ok := bs.findEdge(con.ID, con.Name, c); ok { 96 | edge.SrcID = src.ID 97 | 98 | bs.Config.Builder.AddEdge(edge) 99 | 100 | total++ 101 | } 102 | }) 103 | } 104 | 105 | return total 106 | } 107 | 108 | func (bs *builderState) matchContainer(cn *Container) (yes bool) { 109 | yes = bs.Config.MatchName(cn.Name) 110 | 111 | cn.IterOutbounds(func(c *Connection) { 112 | if c.Proto == UNIX || c.DstIP.IsLoopback() { 113 | return 114 | } 115 | 116 | rip := c.DstIP.String() 117 | 118 | if lc, ok := bs.KnownIP[rip]; ok { // destination known 119 | if !yes && bs.Config.MatchName(lc.Name) { 120 | yes = true 121 | } 122 | 123 | return 124 | } 125 | 126 | if bs.Config.OnlyLocal && !yes { 127 | return 128 | } 129 | 130 | // destination is remote host, add it 131 | rem, ok := bs.Nodes[rip] 132 | if !ok { 133 | rem = node.External(rip) 134 | bs.Nodes[rip] = rem 135 | bs.Remotes.Add(rip) 136 | } 137 | 138 | rem.Ports.Add(ProcessRemote, &node.Port{ 139 | Kind: c.Proto.String(), 140 | Value: strconv.Itoa(c.DstPort), 141 | Number: c.DstPort, 142 | }) 143 | }) 144 | 145 | return yes 146 | } 147 | 148 | func (bs *builderState) findEdge(cid, cname string, conn *Connection) (rv *node.Edge, ok bool) { 149 | var ( 150 | port = &node.Port{ 151 | Kind: conn.Proto.String(), 152 | } 153 | key string 154 | ) 155 | 156 | rv = &node.Edge{ 157 | SrcName: conn.Process, 158 | Port: port, 159 | } 160 | 161 | switch conn.Proto { 162 | case UNIX: 163 | port.Value = conn.Path 164 | rv.DstID = conn.DstID 165 | default: 166 | key = conn.DstIP.String() 167 | port.Value = strconv.Itoa(conn.DstPort) 168 | port.Number = conn.DstPort 169 | 170 | if conn.DstIP.IsLoopback() { 171 | rv.DstID = cid 172 | } else if ldst, found := bs.KnownIP[key]; found { 173 | rv.DstID = ldst.ID 174 | } 175 | } 176 | 177 | if rv.DstID != "" { 178 | if bs.Config.NoLoops && cid == rv.DstID { 179 | return nil, false 180 | } 181 | 182 | dst, found := bs.Nodes[rv.DstID] 183 | if !found { 184 | return nil, false 185 | } 186 | 187 | dname, found := dst.Ports.Get(port) 188 | if !found { 189 | dname = ProcessUnknown 190 | } 191 | 192 | rv.DstName = dname 193 | 194 | return rv, true 195 | } 196 | 197 | if !bs.Config.MatchName(cname) || bs.Config.OnlyLocal { 198 | return nil, false 199 | } 200 | 201 | if rdst, found := bs.Nodes[key]; found { 202 | rv.DstID = rdst.ID 203 | rv.DstName = ProcessRemote 204 | 205 | return rv, true 206 | } 207 | 208 | return nil, false 209 | } 210 | -------------------------------------------------------------------------------- /internal/graph/compress.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "io" 7 | "log" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/s0rg/set" 12 | "github.com/s0rg/trie" 13 | 14 | "github.com/s0rg/decompose/internal/node" 15 | ) 16 | 17 | const ( 18 | externalGroup = "external" 19 | ) 20 | 21 | type Compressor struct { 22 | b NamedBuilderWriter 23 | nodes map[string]*node.Node // "raw" incoming nodes nodeID -> node 24 | groups map[string]*node.Node // "compressed" nodes groupID -> node 25 | index map[string]string // index holds nodeID -> groupID mapping 26 | conns map[string]map[string][]*node.Port // "raw" connections nodeID -> nodeID -> []port 27 | group string 28 | edges int 29 | diff int 30 | force bool 31 | } 32 | 33 | func NewCompressor( 34 | bldr NamedBuilderWriter, 35 | group string, 36 | diff int, 37 | force bool, 38 | ) *Compressor { 39 | return &Compressor{ 40 | b: bldr, 41 | diff: diff, 42 | force: force, 43 | group: group, 44 | index: make(map[string]string), 45 | nodes: make(map[string]*node.Node), 46 | groups: make(map[string]*node.Node), 47 | conns: make(map[string]map[string][]*node.Port), 48 | } 49 | } 50 | 51 | func (c *Compressor) AddNode(n *node.Node) error { 52 | c.nodes[n.ID] = n 53 | 54 | return nil 55 | } 56 | 57 | func (c *Compressor) AddEdge(e *node.Edge) { 58 | nsrc, ok := c.nodes[e.SrcID] 59 | if !ok { 60 | return 61 | } 62 | 63 | ndst, ok := c.nodes[e.DstID] 64 | if !ok { 65 | return 66 | } 67 | 68 | dmap, ok := c.conns[nsrc.ID] 69 | if !ok { 70 | dmap = make(map[string][]*node.Port) 71 | c.conns[nsrc.ID] = dmap 72 | } 73 | 74 | dmap[ndst.ID] = append(dmap[ndst.ID], e.Port) 75 | c.edges++ 76 | } 77 | 78 | func (c *Compressor) Name() string { 79 | return c.b.Name() + " [compressed]" 80 | } 81 | 82 | func (c *Compressor) Write(w io.Writer) (err error) { 83 | c.buildGroups() 84 | edges, count := c.buildEdges() 85 | 86 | for _, node := range c.groups { 87 | if err = c.b.AddNode(node); err != nil { 88 | return fmt.Errorf("compressor add-node [%s]: %w", c.b.Name(), err) 89 | } 90 | } 91 | 92 | log.Printf("[compress] nodes %d -> %d %.02f%%", 93 | len(c.nodes), 94 | len(c.groups), 95 | percentOf(len(c.nodes)-len(c.groups), len(c.nodes)), 96 | ) 97 | 98 | for src, dmap := range edges { 99 | for dst, ports := range dmap { 100 | for _, port := range ports { 101 | c.b.AddEdge(&node.Edge{ 102 | SrcID: src, 103 | DstID: dst, 104 | Port: port, 105 | }) 106 | } 107 | } 108 | } 109 | 110 | log.Printf("[compress] edges %d -> %d %.02f%%", 111 | c.edges, 112 | count, 113 | percentOf(c.edges-count, c.edges), 114 | ) 115 | 116 | if err = c.b.Write(w); err != nil { 117 | return fmt.Errorf("compressor write [%s]: %w", c.b.Name(), err) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (c *Compressor) buildGroups() { 124 | groups := make(map[string][]string) 125 | 126 | t := trie.New[string]() 127 | seen := make(set.Unordered[string]) 128 | 129 | for _, node := range c.nodes { 130 | seen.Add(node.ID) 131 | 132 | if node.IsExternal() { 133 | continue 134 | } 135 | 136 | t.Add(node.Name, node.ID) 137 | } 138 | 139 | comm := t.Common("", c.diff) 140 | 141 | for _, key := range comm { 142 | nodes := []string{} 143 | 144 | t.Iter(key, func(_, nodeID string) { 145 | nodes = append(nodes, nodeID) 146 | }) 147 | 148 | grp := c.group 149 | if len(nodes) > 1 { 150 | grp = cleanName(key) 151 | } 152 | 153 | for _, nodeID := range nodes { 154 | c.index[nodeID] = grp 155 | 156 | seen.Del(nodeID) 157 | } 158 | 159 | groups[grp] = nodes 160 | } 161 | 162 | seen.Iter(func(id string) (next bool) { 163 | grp := c.group 164 | 165 | if c.nodes[id].IsExternal() { 166 | grp = externalGroup 167 | } 168 | 169 | groups[grp] = append(groups[grp], id) 170 | c.index[id] = grp 171 | 172 | return true 173 | }) 174 | 175 | for grp, nodes := range groups { 176 | batch := make([]*node.Node, len(nodes)) 177 | 178 | for i, nodeID := range nodes { 179 | batch[i] = c.nodes[nodeID] 180 | } 181 | 182 | c.groups[grp] = compressNodes(grp, batch) 183 | } 184 | } 185 | 186 | func (c *Compressor) buildEdges() ( 187 | edges map[string]map[string][]*node.Port, 188 | count int, 189 | ) { 190 | edges = make(map[string]map[string][]*node.Port) 191 | 192 | // initial compression: compress to groups 193 | for src, dmap := range c.conns { 194 | srcg := c.index[src] 195 | 196 | gmap, ok := edges[srcg] 197 | if !ok { 198 | gmap = make(map[string][]*node.Port) 199 | edges[srcg] = gmap 200 | } 201 | 202 | for dst, ports := range dmap { 203 | if src == dst { // skip nodes cycles 204 | continue 205 | } 206 | 207 | dstg := c.index[dst] 208 | if srcg == dstg { 209 | continue // skip groups cycles 210 | } 211 | 212 | gmap[dstg] = append(gmap[dstg], ports...) 213 | } 214 | } 215 | 216 | if c.force { 217 | edges = c.forceCompress(edges) 218 | } 219 | 220 | for _, dmap := range edges { 221 | for dst, ports := range dmap { 222 | ports = compressPorts(ports) 223 | dmap[dst] = ports 224 | count += len(ports) 225 | } 226 | } 227 | 228 | return edges, count 229 | } 230 | 231 | // force compression: remove single-connected groups. 232 | func (c *Compressor) forceCompress( 233 | edges map[string]map[string][]*node.Port, 234 | ) ( 235 | rv map[string]map[string][]*node.Port, 236 | ) { 237 | dsts := make(map[string][]string) 238 | drop := func(k, v string) { 239 | sn := c.groups[k] 240 | if dn, ok := c.groups[v]; ok { 241 | sn.Meta.Tags = append(sn.Meta.Tags, dn.Name) 242 | } 243 | 244 | delete(c.groups, v) 245 | delete(edges[k], v) 246 | delete(edges, v) 247 | } 248 | 249 | for src, dmap := range edges { 250 | if len(dmap) == 1 { 251 | for key := range dmap { 252 | drop(key, src) 253 | } 254 | 255 | continue 256 | } 257 | 258 | for dst := range dmap { 259 | dsts[dst] = append(dsts[dst], src) 260 | } 261 | } 262 | 263 | for k, v := range dsts { 264 | if len(v) == 1 { 265 | drop(v[0], k) 266 | } 267 | } 268 | 269 | return edges 270 | } 271 | 272 | func cleanName(a string) string { 273 | const cutset = "0123456789-" 274 | 275 | return strings.TrimRight(a, cutset) 276 | } 277 | 278 | func compressNodes(id string, nodes []*node.Node) (rv *node.Node) { 279 | ports := &node.Ports{} 280 | tags := make([]string, len(nodes)) 281 | 282 | for i, n := range nodes { 283 | tags[i] = n.Name 284 | 285 | n.Ports.Iter(func(_ string, plist []*node.Port) { 286 | for _, p := range plist { 287 | ports.Add(n.Name, p) 288 | } 289 | }) 290 | } 291 | 292 | ports.Compact() 293 | 294 | name := id 295 | if name != externalGroup { 296 | name = strings.ToUpper(name) 297 | } 298 | 299 | return &node.Node{ 300 | ID: id, 301 | Name: name, 302 | Ports: ports, 303 | Meta: &node.Meta{ 304 | Tags: tags, 305 | }, 306 | } 307 | } 308 | 309 | func compressPorts(ports []*node.Port) (rv []*node.Port) { 310 | slices.SortFunc(ports, func(a, b *node.Port) int { 311 | if a.Kind == b.Kind { 312 | return cmp.Compare(a.Value, b.Value) 313 | } 314 | 315 | return cmp.Compare(a.Kind, b.Kind) 316 | }) 317 | 318 | return slices.CompactFunc(ports, func(a, b *node.Port) bool { 319 | return a.Equal(b) 320 | }) 321 | } 322 | -------------------------------------------------------------------------------- /internal/graph/config.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import "github.com/s0rg/set" 4 | 5 | type Config struct { 6 | Builder Builder 7 | Meta Enricher 8 | Follow set.Unordered[string] 9 | SkipEnv []string 10 | Proto NetProto 11 | OnlyLocal bool 12 | NoLoops bool 13 | Deep bool 14 | } 15 | 16 | func (c *Config) MatchName(v string) (yes bool) { 17 | return c.Follow.Len() == 0 || c.Follow.Has(v) 18 | } 19 | 20 | func (c *Config) MatchProto(v string) (yes bool) { 21 | return c.Proto == ALL || v == c.Proto.String() 22 | } 23 | -------------------------------------------------------------------------------- /internal/graph/connection.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "hash/fnv" 5 | "io" 6 | "net" 7 | "strconv" 8 | ) 9 | 10 | type Connection struct { 11 | Process string 12 | DstID string 13 | Path string 14 | SrcIP net.IP 15 | DstIP net.IP 16 | Inode uint64 17 | SrcPort int 18 | DstPort int 19 | Proto NetProto 20 | Listen bool 21 | } 22 | 23 | func (c *Connection) IsListener() bool { 24 | return c.Listen 25 | } 26 | 27 | func (c *Connection) IsInbound() bool { 28 | if c.Proto == UNIX { 29 | return c.Listen 30 | } 31 | 32 | return c.SrcPort < c.DstPort 33 | } 34 | 35 | func (c *Connection) IsLocal() bool { 36 | if c.Proto == UNIX { 37 | return false 38 | } 39 | 40 | return c.SrcIP.IsLoopback() 41 | } 42 | 43 | func (c *Connection) UniqID() (id uint64, ok bool) { 44 | var key string 45 | 46 | switch { 47 | case c.Proto == UNIX: 48 | key = c.Path 49 | case c.IsListener(): 50 | key = c.Proto.String() + strconv.Itoa(c.SrcPort) 51 | case !c.IsInbound(): 52 | key = c.DstIP.String() + c.Proto.String() + strconv.Itoa(c.DstPort) 53 | default: 54 | return 55 | } 56 | 57 | h := fnv.New64a() 58 | _, _ = io.WriteString(h, c.Process+key) 59 | 60 | return h.Sum64(), true 61 | } 62 | -------------------------------------------------------------------------------- /internal/graph/connection_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/graph" 7 | ) 8 | 9 | func TestConnectionIsListener(t *testing.T) { 10 | t.Parallel() 11 | 12 | c := graph.Connection{} 13 | 14 | if c.IsListener() { 15 | t.Fail() 16 | } 17 | 18 | c.Listen = true 19 | 20 | if !c.IsListener() { 21 | t.Fail() 22 | } 23 | } 24 | 25 | func TestConnectionIsInbound(t *testing.T) { 26 | t.Parallel() 27 | 28 | c := graph.Connection{} 29 | 30 | c.DstPort = 1 31 | 32 | if !c.IsInbound() { 33 | t.Fail() 34 | } 35 | 36 | c.SrcPort = 2 37 | 38 | if c.IsInbound() { 39 | t.Fail() 40 | } 41 | } 42 | 43 | func TestConnectionUNIX(t *testing.T) { 44 | t.Parallel() 45 | 46 | const uniqID = "/some/unix.sock" 47 | 48 | c := graph.Connection{ 49 | Proto: graph.UNIX, 50 | Path: uniqID, 51 | } 52 | 53 | if c.IsLocal() { 54 | t.Fail() 55 | } 56 | 57 | if c.IsInbound() { 58 | t.Fail() 59 | } 60 | 61 | c.Listen = true 62 | 63 | if !c.IsInbound() { 64 | t.Fail() 65 | } 66 | 67 | if _, ok := c.UniqID(); !ok { 68 | t.Fail() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/graph/conngroup.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "cmp" 5 | "slices" 6 | 7 | "github.com/s0rg/set" 8 | ) 9 | 10 | type ConnGroup struct { 11 | listenSeen set.Unordered[uint64] 12 | connSeen set.Unordered[uint64] 13 | listen []*Connection 14 | connected []*Connection 15 | } 16 | 17 | func (cg *ConnGroup) Len() (rv int) { 18 | return len(cg.listen) + len(cg.connected) 19 | } 20 | 21 | func (cg *ConnGroup) AddListener(c *Connection) { 22 | cid, ok := c.UniqID() 23 | if !ok { 24 | return 25 | } 26 | 27 | if cg.listenSeen == nil { 28 | cg.listenSeen = make(set.Unordered[uint64]) 29 | } 30 | 31 | if !cg.listenSeen.Add(cid) { 32 | return 33 | } 34 | 35 | cg.listen = append(cg.listen, c) 36 | } 37 | 38 | func (cg *ConnGroup) AddOutbound(c *Connection) { 39 | cid, ok := c.UniqID() 40 | if !ok { 41 | return 42 | } 43 | 44 | if cg.connSeen == nil { 45 | cg.connSeen = make(set.Unordered[uint64]) 46 | } 47 | 48 | if !cg.connSeen.Add(cid) { 49 | return 50 | } 51 | 52 | cg.connected = append(cg.connected, c) 53 | } 54 | 55 | func (cg *ConnGroup) IterOutbounds(it func(*Connection)) { 56 | for _, con := range cg.connected { 57 | it(con) 58 | } 59 | } 60 | 61 | func (cg *ConnGroup) IterListeners(it func(*Connection)) { 62 | for _, con := range cg.listen { 63 | it(con) 64 | } 65 | } 66 | 67 | func (cg *ConnGroup) Sort() { 68 | slices.SortFunc(cg.listen, compare) 69 | slices.SortFunc(cg.connected, compare) 70 | } 71 | 72 | func compare(a, b *Connection) int { 73 | if a.Proto == b.Proto { 74 | return cmp.Compare(a.SrcPort, b.SrcPort) 75 | } 76 | 77 | return cmp.Compare(a.Proto, b.Proto) 78 | } 79 | -------------------------------------------------------------------------------- /internal/graph/conngroup_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/graph" 7 | ) 8 | 9 | func TestConnGroupListeners(t *testing.T) { 10 | t.Parallel() 11 | 12 | cg := &graph.ConnGroup{} 13 | 14 | if cg.Len() > 0 { 15 | t.Fail() 16 | } 17 | 18 | cg.AddListener(&graph.Connection{Proto: graph.TCP, SrcPort: 1}) 19 | cg.AddListener(&graph.Connection{Proto: graph.UDP, SrcPort: 1}) 20 | 21 | cg.AddListener(&graph.Connection{Proto: graph.TCP, SrcPort: 1}) // duplicate 22 | cg.AddListener(&graph.Connection{Proto: graph.TCP, DstPort: 1}) // invalid 23 | 24 | if cg.Len() != 2 { 25 | t.Fail() 26 | } 27 | 28 | cg.Sort() 29 | 30 | cg.IterOutbounds(func(_ *graph.Connection) { 31 | t.Fail() 32 | }) 33 | 34 | cg.IterListeners(func(c *graph.Connection) { 35 | if c.SrcPort != 1 { 36 | t.Fail() 37 | } 38 | }) 39 | } 40 | 41 | func TestConnGroupOutbounds(t *testing.T) { 42 | t.Parallel() 43 | 44 | cg := &graph.ConnGroup{} 45 | 46 | if cg.Len() > 0 { 47 | t.Fail() 48 | } 49 | 50 | cg.AddOutbound(&graph.Connection{Proto: graph.TCP, SrcPort: 2, DstPort: 1}) 51 | cg.AddOutbound(&graph.Connection{Proto: graph.UDP, SrcPort: 3, DstPort: 1}) 52 | 53 | cg.AddOutbound(&graph.Connection{Proto: graph.TCP, SrcPort: 2, DstPort: 1}) 54 | cg.AddOutbound(&graph.Connection{Proto: graph.TCP, DstPort: 1}) 55 | 56 | if cg.Len() != 2 { 57 | t.Fail() 58 | } 59 | 60 | cg.Sort() 61 | 62 | cg.IterListeners(func(_ *graph.Connection) { 63 | t.Fail() 64 | }) 65 | 66 | cg.IterOutbounds(func(c *graph.Connection) { 67 | if c.DstPort != 1 { 68 | t.Fail() 69 | } 70 | }) 71 | } 72 | 73 | func TestConnGroupSort(t *testing.T) { 74 | t.Parallel() 75 | 76 | cg := &graph.ConnGroup{} 77 | 78 | cg.AddListener(&graph.Connection{Proto: graph.TCP, SrcPort: 1, Listen: true}) 79 | cg.AddListener(&graph.Connection{Proto: graph.UDP, SrcPort: 1, Listen: true}) 80 | cg.AddListener(&graph.Connection{Proto: graph.TCP, SrcPort: 2, Listen: true}) 81 | cg.AddListener(&graph.Connection{Proto: graph.UDP, SrcPort: 2, Listen: true}) 82 | cg.AddListener(&graph.Connection{Proto: graph.TCP, SrcPort: 3, Listen: true}) 83 | cg.AddListener(&graph.Connection{Proto: graph.UDP, SrcPort: 3, Listen: true}) 84 | 85 | cg.AddOutbound(&graph.Connection{Proto: graph.TCP, SrcPort: 2, DstPort: 1}) 86 | cg.AddOutbound(&graph.Connection{Proto: graph.UDP, SrcPort: 3, DstPort: 1}) 87 | cg.AddOutbound(&graph.Connection{Proto: graph.TCP, SrcPort: 4, DstPort: 2}) 88 | cg.AddOutbound(&graph.Connection{Proto: graph.UDP, SrcPort: 5, DstPort: 2}) 89 | 90 | cg.Sort() 91 | 92 | if cg.Len() != 10 { 93 | t.Fail() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/graph/container.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "slices" 5 | "strconv" 6 | 7 | "github.com/s0rg/decompose/internal/node" 8 | ) 9 | 10 | type ( 11 | ContainerInfo struct { 12 | Cmd []string 13 | Env []string 14 | } 15 | 16 | VolumeInfo struct { 17 | Type string 18 | Src string 19 | Dst string 20 | } 21 | 22 | Container struct { 23 | Endpoints map[string]string 24 | Labels map[string]string 25 | conns map[string]*ConnGroup 26 | connOrder []string 27 | ID string 28 | Name string 29 | Image string 30 | Info *ContainerInfo 31 | Volumes []*VolumeInfo 32 | } 33 | ) 34 | 35 | func (c *Container) ConnectionsCount() (rv int) { 36 | for _, cg := range c.conns { 37 | rv += cg.Len() 38 | } 39 | 40 | return rv 41 | } 42 | 43 | func (c *Container) AddConnection(conn *Connection) { 44 | if c.conns == nil { 45 | c.conns = make(map[string]*ConnGroup) 46 | } 47 | 48 | var seen bool 49 | 50 | grp, seen := c.conns[conn.Process] 51 | if !seen { 52 | grp = &ConnGroup{} 53 | } 54 | 55 | switch { 56 | case conn.IsListener(): 57 | grp.AddListener(conn) 58 | case !conn.IsInbound(): 59 | grp.AddOutbound(conn) 60 | default: 61 | return 62 | } 63 | 64 | if !seen { 65 | c.connOrder = append(c.connOrder, conn.Process) 66 | slices.Sort(c.connOrder) 67 | } 68 | 69 | c.conns[conn.Process] = grp 70 | } 71 | 72 | func (c *Container) AddMany(conns []*Connection) { 73 | for _, conn := range conns { 74 | c.AddConnection(conn) 75 | } 76 | } 77 | 78 | func (c *Container) IterOutbounds(it func(*Connection)) { 79 | for _, k := range c.connOrder { 80 | c.conns[k].IterOutbounds(it) 81 | } 82 | } 83 | 84 | func (c *Container) IterListeners(it func(*Connection)) { 85 | for _, k := range c.connOrder { 86 | c.conns[k].IterListeners(it) 87 | } 88 | } 89 | 90 | func (c *Container) SortConnections() { 91 | for _, cg := range c.conns { 92 | cg.Sort() 93 | } 94 | } 95 | 96 | func (c *Container) ToNode() (rv *node.Node) { 97 | rv = &node.Node{ 98 | ID: c.ID, 99 | Name: c.Name, 100 | Image: c.Image, 101 | Ports: &node.Ports{}, 102 | Volumes: make([]*node.Volume, len(c.Volumes)), 103 | Networks: make([]string, 0, len(c.Endpoints)), 104 | } 105 | 106 | for _, n := range c.Endpoints { 107 | rv.Networks = append(rv.Networks, n) 108 | } 109 | 110 | c.IterListeners(func(conn *Connection) { 111 | value := conn.Path 112 | 113 | if conn.Proto != UNIX { 114 | value = strconv.Itoa(conn.SrcPort) 115 | } 116 | 117 | rv.Ports.Add(conn.Process, &node.Port{ 118 | Local: conn.IsLocal(), 119 | Kind: conn.Proto.String(), 120 | Value: value, 121 | }) 122 | }) 123 | 124 | for idx, v := range c.Volumes { 125 | rv.Volumes[idx] = &node.Volume{ 126 | Type: v.Type, 127 | Src: v.Src, 128 | Dst: v.Dst, 129 | } 130 | } 131 | 132 | rv.Container.Labels = c.Labels 133 | 134 | if c.Info != nil { 135 | rv.Container.Cmd = c.Info.Cmd 136 | rv.Container.Env = c.Info.Env 137 | } 138 | 139 | return rv 140 | } 141 | -------------------------------------------------------------------------------- /internal/graph/container_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/graph" 7 | ) 8 | 9 | var testCases = []struct { 10 | Conns []*graph.Connection 11 | Listeners int 12 | Outbounds int 13 | Count int 14 | }{ 15 | { 16 | Conns: []*graph.Connection{ 17 | {SrcPort: 1, DstPort: 2}, // inbound 18 | {SrcPort: 1, Listen: true}, // listener 19 | {SrcPort: 2, DstPort: 1}, // outbound 20 | }, 21 | Listeners: 1, 22 | Outbounds: 1, 23 | Count: 2, 24 | }, 25 | { 26 | Conns: []*graph.Connection{ 27 | {SrcPort: 1, DstPort: 2}, // inbound 28 | {SrcPort: 2, DstPort: 1}, // outbound 29 | }, 30 | Listeners: 0, 31 | Outbounds: 1, 32 | Count: 1, 33 | }, 34 | { 35 | Conns: []*graph.Connection{ 36 | {SrcPort: 1, DstPort: 2}, // inbound 37 | {SrcPort: 1, Listen: true}, // listener 38 | }, 39 | Listeners: 1, 40 | Outbounds: 0, 41 | Count: 1, 42 | }, 43 | { 44 | Conns: []*graph.Connection{ 45 | {SrcPort: 1, DstPort: 2}, // inbound 46 | }, 47 | Listeners: 0, 48 | Outbounds: 0, 49 | Count: 0, 50 | }, 51 | } 52 | 53 | func TestContainerListeners(t *testing.T) { 54 | t.Parallel() 55 | 56 | for i := 0; i < len(testCases); i++ { 57 | tc := &testCases[i] 58 | 59 | res := 0 60 | 61 | c := graph.Container{} 62 | c.AddMany(tc.Conns) 63 | c.SortConnections() 64 | c.IterListeners(func(_ *graph.Connection) { 65 | res++ 66 | }) 67 | 68 | if res != tc.Listeners { 69 | t.Fatalf("test case[%d] fail want %d got %d", i, tc.Listeners, res) 70 | } 71 | } 72 | } 73 | 74 | func TestContainerOutbounds(t *testing.T) { 75 | t.Parallel() 76 | 77 | for i := 0; i < len(testCases); i++ { 78 | tc := &testCases[i] 79 | 80 | res := 0 81 | 82 | c := graph.Container{} 83 | c.AddMany(tc.Conns) 84 | c.SortConnections() 85 | c.IterOutbounds(func(_ *graph.Connection) { 86 | res++ 87 | }) 88 | 89 | if res != tc.Outbounds { 90 | t.Fatalf("test case[%d] fail want %d got %d", i, tc.Outbounds, res) 91 | } 92 | } 93 | } 94 | 95 | func TestContainerCount(t *testing.T) { 96 | t.Parallel() 97 | 98 | for i := 0; i < len(testCases); i++ { 99 | tc := &testCases[i] 100 | 101 | c := graph.Container{} 102 | c.AddMany(tc.Conns) 103 | 104 | if res := c.ConnectionsCount(); res != tc.Count { 105 | t.Fatalf("test case[%d] fail want %d got %d", i, tc.Count, res) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/graph/load.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/s0rg/decompose/internal/node" 9 | ) 10 | 11 | const idSuffix = "-id" 12 | 13 | type Loader struct { 14 | nodes map[string]*node.Node 15 | edges map[string]map[string][]*node.Connection 16 | cfg *Config 17 | } 18 | 19 | func NewLoader(cfg *Config) *Loader { 20 | return &Loader{ 21 | cfg: cfg, 22 | nodes: make(map[string]*node.Node), 23 | edges: make(map[string]map[string][]*node.Connection), 24 | } 25 | } 26 | 27 | func (l *Loader) FromReader(r io.Reader) error { 28 | jr := json.NewDecoder(r) 29 | 30 | for jr.More() { 31 | var n node.JSON 32 | 33 | if err := jr.Decode(&n); err != nil { 34 | return fmt.Errorf("decode: %w", err) 35 | } 36 | 37 | l.insert(&n) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (l *Loader) Build() error { 44 | for id, node := range l.nodes { 45 | if l.cfg.OnlyLocal && l.isExternal(id) { 46 | continue 47 | } 48 | 49 | if err := l.cfg.Builder.AddNode(node); err != nil { 50 | return fmt.Errorf("node %s: %w", node.Name, err) 51 | } 52 | } 53 | 54 | for srcID, dmap := range l.edges { 55 | if l.cfg.OnlyLocal && l.isExternal(srcID) { 56 | continue 57 | } 58 | 59 | l.connect(srcID, dmap) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (l *Loader) createNode(id string, n *node.JSON) (rv *node.Node) { 66 | rv = &node.Node{ 67 | ID: id, 68 | Name: n.Name, 69 | Container: n.Container, 70 | Ports: &node.Ports{}, 71 | Networks: []string{}, 72 | } 73 | 74 | if n.Image != nil { 75 | rv.Image = *n.Image 76 | } 77 | 78 | if len(n.Tags) > 0 { 79 | rv.Meta = &node.Meta{ 80 | Tags: n.Tags, 81 | } 82 | } 83 | 84 | if len(n.Networks) > 0 { 85 | rv.Networks = n.Networks 86 | } 87 | 88 | if len(n.Volumes) > 0 { 89 | rv.Volumes = n.Volumes 90 | } 91 | 92 | return rv 93 | } 94 | 95 | func (l *Loader) loadNode(n *node.JSON) (id string, rv *node.Node) { 96 | id = n.Name 97 | if !n.IsExternal { 98 | id += idSuffix 99 | } 100 | 101 | nod, ok := l.nodes[id] 102 | if !ok { 103 | nod = l.createNode(id, n) 104 | } 105 | 106 | if !(n.IsExternal && l.cfg.OnlyLocal) { 107 | loadListeners(nod.Ports, n.Listen) 108 | } 109 | 110 | return id, nod 111 | } 112 | 113 | func (l *Loader) loadEdges(id string, n *node.JSON) (rv map[string][]*node.Connection, skip bool) { 114 | var ok bool 115 | 116 | if rv, ok = l.edges[id]; !ok { 117 | rv = make(map[string][]*node.Connection) 118 | } 119 | 120 | skip = !l.cfg.MatchName(n.Name) 121 | 122 | for k, p := range n.Connected { 123 | if l.cfg.NoLoops && n.Name == k { 124 | continue 125 | } 126 | 127 | if !skip || l.cfg.MatchName(k) { 128 | rv[k] = append(rv[k], p...) 129 | skip = false 130 | } 131 | } 132 | 133 | return rv, skip 134 | } 135 | 136 | func (l *Loader) insert(n *node.JSON) { 137 | id, nod := l.loadNode(n) 138 | cons, skip := l.loadEdges(id, n) 139 | 140 | if skip { 141 | return 142 | } 143 | 144 | l.cfg.Meta.Enrich(nod) 145 | 146 | nod.Ports.Compact() 147 | 148 | l.nodes[id] = nod 149 | l.edges[id] = cons 150 | } 151 | 152 | func (l *Loader) isExternal(id string) (yes bool) { 153 | n, ok := l.nodes[id] 154 | if !ok { 155 | return false 156 | } 157 | 158 | return n.IsExternal() 159 | } 160 | 161 | func (l *Loader) connect( 162 | srcID string, 163 | conns map[string][]*node.Connection, 164 | ) { 165 | for dstID, cl := range conns { 166 | if l.isExternal(dstID) { 167 | if l.cfg.OnlyLocal { 168 | continue 169 | } 170 | } else { 171 | dstID += idSuffix 172 | } 173 | 174 | for _, c := range cl { 175 | if !l.cfg.MatchProto(c.Port.Kind) { 176 | continue 177 | } 178 | 179 | l.cfg.Builder.AddEdge(&node.Edge{ 180 | SrcID: srcID, 181 | DstID: dstID, 182 | SrcName: c.Src, 183 | DstName: c.Dst, 184 | Port: c.Port, 185 | }) 186 | } 187 | } 188 | } 189 | 190 | func loadListeners( 191 | ports *node.Ports, 192 | conns map[string][]*node.Port, 193 | ) { 194 | for k, cl := range conns { 195 | for _, p := range cl { 196 | ports.Add(k, p) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /internal/graph/meta.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/s0rg/decompose/internal/node" 12 | ) 13 | 14 | type ( 15 | MetaLoader struct { 16 | state map[string]*node.Meta 17 | } 18 | 19 | match struct { 20 | Key string 21 | Weight int 22 | } 23 | ) 24 | 25 | func NewMetaLoader() *MetaLoader { 26 | return &MetaLoader{ 27 | state: make(map[string]*node.Meta), 28 | } 29 | } 30 | 31 | func (ex *MetaLoader) FromReader(r io.Reader) error { 32 | d := json.NewDecoder(r) 33 | 34 | for d.More() { 35 | if err := d.Decode(&ex.state); err != nil { 36 | return fmt.Errorf("decode: %w", err) 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (ex *MetaLoader) Enrich(n *node.Node) { 44 | if len(ex.state) == 0 { 45 | return 46 | } 47 | 48 | matches := []*match{} 49 | 50 | for key := range ex.state { 51 | if !strings.HasPrefix(n.Name, key) { 52 | continue 53 | } 54 | 55 | matches = append(matches, &match{ 56 | Key: key, 57 | Weight: len(n.Name) - len(key), 58 | }) 59 | } 60 | 61 | var meta *node.Meta 62 | 63 | switch len(matches) { 64 | default: 65 | slices.SortFunc(matches, func(a, b *match) int { 66 | return cmp.Compare(a.Weight, b.Weight) 67 | }) 68 | 69 | fallthrough 70 | case 1: 71 | meta = ex.state[matches[0].Key] 72 | case 0: 73 | return 74 | } 75 | 76 | n.Meta = meta 77 | } 78 | -------------------------------------------------------------------------------- /internal/graph/meta_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/graph" 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | func TestExtraFromReaderErr(t *testing.T) { 13 | t.Parallel() 14 | 15 | r := bytes.NewBufferString(`{`) 16 | l := graph.NewMetaLoader() 17 | 18 | if err := l.FromReader(r); err == nil { 19 | t.Fatal("err nil") 20 | } 21 | } 22 | 23 | func TestExtraEnrich(t *testing.T) { 24 | t.Parallel() 25 | 26 | r := bytes.NewBufferString(` 27 | { 28 | "foo": { 29 | "info": "its a foo", 30 | "tags": ["foo"] 31 | }, 32 | "baz": { 33 | "info": "its a baz", 34 | "tags": ["not-foo", "baz"] 35 | }, 36 | "bazooka": { 37 | "info": "its a bazooka!", 38 | "tags": ["not-baz", "not-foo", "boom"] 39 | } 40 | } 41 | `) 42 | 43 | testCases := []struct { 44 | WantInfoKey string 45 | Node node.Node 46 | WantTagsNum int 47 | Want bool 48 | }{ 49 | { 50 | Node: node.Node{Name: "foo-1"}, 51 | Want: true, 52 | WantInfoKey: "foo", 53 | WantTagsNum: 1, 54 | }, 55 | { 56 | Node: node.Node{Name: "baz-2"}, 57 | Want: true, 58 | WantInfoKey: "baz", 59 | WantTagsNum: 2, 60 | }, 61 | { 62 | Node: node.Node{Name: "bazooka-1"}, 63 | Want: true, 64 | WantInfoKey: "bazooka", 65 | WantTagsNum: 3, 66 | }, 67 | 68 | { 69 | Node: node.Node{Name: "bar-1"}, 70 | }, 71 | } 72 | 73 | l := graph.NewMetaLoader() 74 | 75 | // empty state test 76 | 77 | n := testCases[0].Node 78 | 79 | l.Enrich(&n) 80 | 81 | if n.Meta != nil { 82 | t.Fatal("not nill") 83 | } 84 | 85 | if err := l.FromReader(r); err != nil { 86 | t.Fatal("reader err=", err) 87 | } 88 | 89 | for i, tc := range testCases { 90 | n := tc.Node 91 | 92 | l.Enrich(&n) 93 | 94 | if tc.Want && n.Meta == nil { 95 | t.Fatalf("case[%d] state", i) 96 | } 97 | 98 | if !tc.Want { 99 | continue 100 | } 101 | 102 | if !strings.Contains(n.Meta.Info, tc.WantInfoKey) { 103 | t.Fatalf("case[%d] extra key", i) 104 | } 105 | 106 | if len(n.Meta.Tags) != tc.WantTagsNum { 107 | t.Fatalf("case[%d] tags", i) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/graph/netproto.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import "strings" 4 | 5 | type NetProto int16 6 | 7 | const ( 8 | TCP NetProto = 1 << iota 9 | UDP NetProto = 1 << iota 10 | UNIX NetProto = 1 << iota 11 | NONE NetProto = 0 12 | ALL NetProto = TCP | UDP | UNIX 13 | pMAX = 3 14 | 15 | sNONE = "none" 16 | sTCP = "tcp" 17 | sUDP = "udp" 18 | sUNIX = "unix" 19 | sALL = "all" 20 | ) 21 | 22 | func (p NetProto) String() string { 23 | buf := make([]string, 0, pMAX) 24 | 25 | if p.Has(TCP) { 26 | buf = append(buf, sTCP) 27 | } 28 | 29 | if p.Has(UDP) { 30 | buf = append(buf, sUDP) 31 | } 32 | 33 | if p.Has(UNIX) { 34 | buf = append(buf, sUNIX) 35 | } 36 | 37 | if len(buf) == 0 { 38 | buf = append(buf, sNONE) 39 | } 40 | 41 | return strings.Join(buf, ",") 42 | } 43 | 44 | func (p *NetProto) Set(mask NetProto) { 45 | *p |= mask 46 | } 47 | 48 | func (p NetProto) Has(mask NetProto) bool { 49 | return (p & mask) == mask 50 | } 51 | 52 | func ParseNetProto(val string) (p NetProto, ok bool) { 53 | items := strings.Split(val, ",") 54 | 55 | for _, v := range items { 56 | switch v { 57 | case sALL: 58 | p.Set(ALL) 59 | case sTCP: 60 | p.Set(TCP) 61 | case sUDP: 62 | p.Set(UDP) 63 | case sUNIX: 64 | p.Set(UNIX) 65 | default: 66 | return 67 | } 68 | } 69 | 70 | return p, true 71 | } 72 | -------------------------------------------------------------------------------- /internal/graph/netproto_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/graph" 7 | ) 8 | 9 | func TestParseNetproto(t *testing.T) { 10 | t.Parallel() 11 | 12 | testCases := []struct { 13 | Val string 14 | Valid bool 15 | Want graph.NetProto 16 | }{ 17 | {Val: "tcp", Valid: true, Want: graph.TCP}, 18 | {Val: "udp", Valid: true, Want: graph.UDP}, 19 | {Val: "all", Valid: true, Want: graph.ALL}, 20 | {Val: "unix", Valid: true, Want: graph.UNIX}, 21 | {Val: "bad", Valid: false}, 22 | } 23 | 24 | for i, tc := range testCases { 25 | got, ok := graph.ParseNetProto(tc.Val) 26 | 27 | if ok != tc.Valid { 28 | t.Fatalf("case[%d] failed for '%s' want: %t got: %t", i, tc.Val, tc.Valid, ok) 29 | } 30 | 31 | if !ok { 32 | continue 33 | } 34 | 35 | if got != tc.Want { 36 | t.Fatalf("case[%d] failed for '%s' want: %s got: %s", i, tc.Val, tc.Want.String(), got.String()) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/graph/netstat.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "net" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | stateListen = "LISTEN" 14 | stateEstablished = "ESTABLISHED" 15 | netstatCmd = "netstat" 16 | netstatArg = "-apn" 17 | ) 18 | 19 | func netstatArgFor(p NetProto) (rv string) { 20 | if p.Has(TCP) { 21 | rv += "t" 22 | } 23 | 24 | if p.Has(UDP) { 25 | rv += "u" 26 | } 27 | 28 | return rv 29 | } 30 | 31 | func NetstatCMD(p NetProto) []string { 32 | return []string{ 33 | netstatCmd, 34 | netstatArg + netstatArgFor(p), 35 | } 36 | } 37 | 38 | func ParseNetstat(r io.Reader, cb func(*Connection)) (err error) { 39 | s := bufio.NewScanner(r) 40 | s.Split(bufio.ScanLines) 41 | 42 | const nSkipHead = 2 43 | 44 | var ( 45 | conn *Connection 46 | ok bool 47 | ) 48 | 49 | for i := 0; s.Scan(); i++ { 50 | if i < nSkipHead { 51 | continue 52 | } 53 | 54 | if conn, ok = parseConnection(s.Text()); !ok { 55 | continue 56 | } 57 | 58 | cb(conn) 59 | } 60 | 61 | if err = s.Err(); err != nil { 62 | return fmt.Errorf("scan: %w", err) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func parseConnection(s string) (conn *Connection, ok bool) { 69 | const minFields = 6 70 | 71 | parts := strings.Fields(s) 72 | if len(parts) < minFields { 73 | return nil, false 74 | } 75 | 76 | proto, ok := parseKind(parts[0], len(parts)) 77 | if !ok { 78 | return nil, false 79 | } 80 | 81 | conn = &Connection{ 82 | Proto: proto, 83 | } 84 | 85 | if conn.SrcIP, conn.SrcPort, ok = splitIP(parts[3]); !ok { 86 | return nil, false 87 | } 88 | 89 | if conn.DstIP, conn.DstPort, ok = splitIP(parts[4]); !ok { 90 | return nil, false 91 | } 92 | 93 | var nProcField = 5 94 | 95 | if conn.Proto == TCP { 96 | nProcField = 6 97 | 98 | switch parts[5] { 99 | case stateListen: 100 | conn.Listen = true 101 | case stateEstablished: 102 | default: // skip all other states 103 | return nil, false 104 | } 105 | } else { 106 | conn.Listen = (conn.DstPort > 0 && conn.SrcPort < conn.DstPort) || conn.DstPort == 0 107 | } 108 | 109 | if conn.Process, ok = splitName(parts[nProcField]); !ok { 110 | return nil, false 111 | } 112 | 113 | return conn, true 114 | } 115 | 116 | func parseKind(kind string, fieldsNum int) (k NetProto, ok bool) { 117 | const ( 118 | nPartsUDP = 6 119 | nPartsTCP = 7 120 | ) 121 | 122 | switch { 123 | case strings.HasPrefix(kind, TCP.String()) && fieldsNum >= nPartsTCP: 124 | return TCP, true 125 | case strings.HasPrefix(kind, UDP.String()) && fieldsNum >= nPartsUDP: 126 | return UDP, true 127 | default: // unknown protocol or invalid fields count 128 | } 129 | 130 | return 131 | } 132 | 133 | func splitIP(v string) (ip net.IP, port int, ok bool) { 134 | idx := strings.LastIndexByte(v, ':') 135 | if idx < 0 { 136 | return 137 | } 138 | 139 | addr, sport := v[:idx], v[idx+1:] 140 | 141 | if ip = net.ParseIP(addr); ip == nil { 142 | return 143 | } 144 | 145 | if sport != "*" { 146 | uval, err := strconv.ParseUint(sport, 10, 16) 147 | if err != nil { 148 | return 149 | } 150 | 151 | port = int(uval) 152 | } 153 | 154 | return ip, port, true 155 | } 156 | 157 | func splitName(v string) (name string, ok bool) { 158 | const pidFields = 2 159 | 160 | parts := strings.SplitN(v, "/", pidFields) 161 | if len(parts) != pidFields { 162 | return 163 | } 164 | 165 | fields := strings.Fields(parts[1]) 166 | if len(fields) == 0 { 167 | return 168 | } 169 | 170 | if name = fields[0]; name == "" { 171 | return 172 | } 173 | 174 | return name, true 175 | } 176 | -------------------------------------------------------------------------------- /internal/graph/netstat_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/graph" 9 | ) 10 | 11 | type failReader struct { 12 | Err error 13 | } 14 | 15 | func (fr *failReader) Read(_ []byte) (n int, err error) { 16 | return 0, fr.Err 17 | } 18 | 19 | func TestParseNetstat(t *testing.T) { 20 | t.Parallel() 21 | 22 | b := bytes.NewBufferString(`Active Internet connections (servers and established) 23 | Proto Recv-Q Send-Q Local Address Foreign Address State 24 | tcp 0 0 0.0.0.0:2333 0.0.0.0:* LISTEN 1/foo 25 | tcp 0 0 172.20.4.209:1666 0.0.0.0:* LISTEN 2/bar 26 | tcp 0 0 172.20.4.209:48020 172.20.4.198:3306 TIME_WAIT 2/bar 27 | tcp 0 0 172.20.4.209:1665 172.20.5.76:38512 ESTABLISHED 1/foo 28 | tcp 1 0 172.20.4.209:43534 172.20.4.129:53 CLOSE_WAIT 2/bar 29 | tcp 0 0 172.20.4.209:48021 172.20.4.198:3306 ESTABLISHED 1/foo 30 | tcp6 0 0 :::6501 :::* LISTEN 2/bar 31 | tcp6 0 0 :::1234 :::* LISTEN 1/foo 32 | tcp6 0 0 127.0.0.1:6501 127.0.0.1:43706 ESTABLISHED 2/bar 33 | tcp 1 0 172.20.4.209:43634 172.20.4.129:53 ESTABLISHED bar/ 34 | tcp 1 0 172.20.4.209:43634 172.20.4.129:53 ESTABLISHED bar 35 | udp 0 0 127.0.0.1:56688 10.10.0.1:54 11/ntpd 36 | udp 0 0 0.0.0.0:455 0.0.0.0:* 10/ntpd 37 | bgp 1 1 127.0.0.11:56689 0.0.0.0:* LISTEN 1/foo 38 | tcp 0 0 invalid 172.20.4.198:3306 ESTABLISHED - 39 | tcp 0 0 172.20.4.198:3306 invalid ESTABLISHED - 40 | tcp 0 0 172.20.4.198:bad 172.20.4.198:3306 ESTABLISHED - 41 | tcp 0 0 invalid-ip:123 172.20.4.198:3306 ESTABLISHED - 42 | Active UNIX domain sockets (servers and established) 43 | Proto RefCnt Flags Type State I-Node PID/Program name Path 44 | unix 3 [ ] STREAM CONNECTED 38047 1/init /run/systemd/journal/stdout 45 | unix 3 [ ] STREAM CONNECTED 27351 4452/wireplumber 46 | unix 2 [ ACC ] STREAM LISTENING 23216 2645/Xorg @/tmp/.X11-unix/X0 47 | unix 2 [ ] DGRAM 39148 4797/xdg-desktop-po 48 | 49 | some garbage 50 | `) 51 | 52 | con := graph.Container{} 53 | 54 | if err := graph.ParseNetstat(b, func(c *graph.Connection) { 55 | con.AddConnection(c) 56 | }); err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | if con.ConnectionsCount() != 7 { 61 | t.Log("total:", con.ConnectionsCount()) 62 | t.Fail() 63 | } 64 | 65 | var nlisten, noutbound int 66 | 67 | con.IterListeners(func(_ *graph.Connection) { 68 | nlisten++ 69 | }) 70 | con.IterOutbounds(func(_ *graph.Connection) { 71 | noutbound++ 72 | }) 73 | 74 | if nlisten != 5 || noutbound != 2 { 75 | t.Log("listen/outbound:", nlisten, noutbound) 76 | t.Fail() 77 | } 78 | } 79 | 80 | func TestParseNetstatError(t *testing.T) { 81 | t.Parallel() 82 | 83 | myErr := errors.New("test-err") 84 | reader := &failReader{Err: myErr} 85 | 86 | err := graph.ParseNetstat(reader, func(*graph.Connection) {}) 87 | if err == nil { 88 | t.Fatal("err == nil") 89 | } 90 | 91 | if !errors.Is(err, myErr) { 92 | t.Fail() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/graph/orphans.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/s0rg/decompose/internal/node" 8 | 9 | "github.com/s0rg/set" 10 | ) 11 | 12 | const orphansName = "no-orphans" 13 | 14 | type OrphansInspector struct { 15 | b NamedBuilderWriter 16 | o set.Unordered[string] 17 | n []*node.Node 18 | e []*node.Edge 19 | } 20 | 21 | func NewOrphansInspector(b NamedBuilderWriter) *OrphansInspector { 22 | return &OrphansInspector{ 23 | b: b, 24 | o: make(set.Unordered[string]), 25 | } 26 | } 27 | 28 | func (o *OrphansInspector) Name() string { 29 | return o.b.Name() + " " + orphansName 30 | } 31 | 32 | func (o *OrphansInspector) AddNode(n *node.Node) error { 33 | o.n = append(o.n, n) 34 | o.o.Add(n.ID) 35 | 36 | return nil 37 | } 38 | 39 | func (o *OrphansInspector) AddEdge(e *node.Edge) { 40 | o.e = append(o.e, e) 41 | o.o.Del(e.SrcID) 42 | o.o.Del(e.DstID) 43 | } 44 | 45 | func (o *OrphansInspector) Write(w io.Writer) (err error) { 46 | for _, n := range o.n { 47 | if o.o.Has(n.ID) { 48 | continue 49 | } 50 | 51 | if err = o.b.AddNode(n); err != nil { 52 | return fmt.Errorf("no-orphans add node: %w", err) 53 | } 54 | } 55 | 56 | for _, e := range o.e { 57 | o.b.AddEdge(e) 58 | } 59 | 60 | if err = o.b.Write(w); err != nil { 61 | return fmt.Errorf("no-orphans write: %w", err) 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/graph/orphans_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/s0rg/decompose/internal/graph" 9 | "github.com/s0rg/decompose/internal/node" 10 | ) 11 | 12 | func TestOrphans(t *testing.T) { 13 | t.Parallel() 14 | 15 | tb := testNamedBuilder{} 16 | op := graph.NewOrphansInspector(&tb) 17 | 18 | name := op.Name() 19 | 20 | if !strings.Contains(name, tb.Name()) { 21 | t.Fail() 22 | } 23 | 24 | op.AddNode(&node.Node{ID: "1"}) 25 | op.AddNode(&node.Node{ID: "2"}) 26 | op.AddNode(&node.Node{ID: "3"}) 27 | 28 | op.AddEdge(&node.Edge{SrcID: "1", DstID: "3"}) 29 | op.AddEdge(&node.Edge{SrcID: "3", DstID: "1"}) 30 | 31 | _ = op.Write(io.Discard) 32 | 33 | if tb.Edges != 2 || tb.Nodes != 2 { 34 | t.Fail() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/node/edge.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type Edge struct { 4 | Port *Port 5 | SrcID string 6 | SrcName string 7 | DstID string 8 | DstName string 9 | } 10 | -------------------------------------------------------------------------------- /internal/node/json.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type Container struct { 4 | Cmd []string `json:"cmd,omitempty"` 5 | Env []string `json:"env,omitempty"` 6 | Labels map[string]string `json:"labels"` 7 | } 8 | 9 | type Volume struct { 10 | Type string `json:"type"` 11 | Src string `json:"src"` 12 | Dst string `json:"dst"` 13 | } 14 | 15 | type Meta struct { 16 | Info string `json:"info"` 17 | Docs string `json:"docs"` 18 | Repo string `json:"repo"` 19 | Tags []string `json:"tags"` 20 | } 21 | 22 | type Connection struct { 23 | Port *Port `json:"port"` 24 | Src string `json:"src"` 25 | Dst string `json:"dst"` 26 | } 27 | 28 | type JSON struct { 29 | Name string `json:"name"` 30 | IsExternal bool `json:"is_external"` 31 | Image *string `json:"image,omitempty"` 32 | Networks []string `json:"networks"` 33 | Tags []string `json:"tags"` 34 | Volumes []*Volume `json:"volumes"` 35 | Container Container `json:"container"` 36 | Listen map[string][]*Port `json:"listen"` 37 | Connected map[string][]*Connection `json:"connected"` 38 | } 39 | -------------------------------------------------------------------------------- /internal/node/node.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "path/filepath" 5 | "slices" 6 | ) 7 | 8 | type Node struct { 9 | Container Container 10 | Meta *Meta 11 | Ports *Ports 12 | ID string 13 | Name string 14 | Image string 15 | Cluster string 16 | Networks []string 17 | Volumes []*Volume 18 | } 19 | 20 | func External(name string) (rv *Node) { 21 | return &Node{ 22 | ID: name, 23 | Name: name, 24 | Ports: &Ports{}, 25 | } 26 | } 27 | 28 | func (n *Node) IsExternal() bool { 29 | return n.ID == n.Name 30 | } 31 | 32 | func (n *Node) ToJSON() (rv *JSON) { 33 | rv = &JSON{ 34 | Name: n.Name, 35 | IsExternal: n.IsExternal(), 36 | Networks: n.Networks, 37 | Container: n.Container, 38 | Listen: make(map[string][]*Port), 39 | Volumes: []*Volume{}, 40 | Tags: []string{}, 41 | Connected: make(map[string][]*Connection), 42 | } 43 | 44 | if n.Meta != nil { 45 | rv.Tags = n.Meta.Tags 46 | } 47 | 48 | if n.Image != "" { 49 | rv.Image = &n.Image 50 | } 51 | 52 | n.Ports.Compact() 53 | 54 | n.Ports.Iter(func(name string, ports []*Port) { 55 | rv.Listen[name] = ports 56 | }) 57 | 58 | if len(n.Volumes) > 0 { 59 | rv.Volumes = n.Volumes 60 | } 61 | 62 | return rv 63 | } 64 | 65 | func (n *Node) ToView() (rv *View) { 66 | rv = &View{ 67 | Name: n.Name, 68 | Image: n.Image, 69 | Listen: n.Ports, 70 | IsExternal: n.IsExternal(), 71 | } 72 | 73 | if n.Meta != nil && len(n.Meta.Tags) > 0 { 74 | rv.Tags = n.Meta.Tags 75 | } 76 | 77 | if len(n.Container.Cmd) > 0 { 78 | rv.Cmd = filepath.Base(n.Container.Cmd[0]) 79 | rv.Args = n.Container.Cmd[1:] 80 | } 81 | 82 | return rv 83 | } 84 | 85 | func (n *Node) FormatMeta() (rv []string, ok bool) { 86 | if n.Meta == nil { 87 | return 88 | } 89 | 90 | const maxMeta = 3 91 | 92 | rv = make([]string, 0, maxMeta) 93 | 94 | if n.Meta.Info != "" { 95 | rv = append(rv, n.Meta.Info) 96 | } 97 | 98 | if n.Meta.Docs != "" { 99 | rv = append(rv, n.Meta.Docs) 100 | } 101 | 102 | if n.Meta.Repo != "" { 103 | rv = append(rv, n.Meta.Repo) 104 | } 105 | 106 | return slices.Clip(rv), len(rv) > 0 107 | } 108 | -------------------------------------------------------------------------------- /internal/node/node_test.go: -------------------------------------------------------------------------------- 1 | package node_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/node" 7 | ) 8 | 9 | func makeTestNode( 10 | id, name, image string, 11 | ports []*node.Port, 12 | ) (rv *node.Node) { 13 | rv = &node.Node{ 14 | ID: id, 15 | Name: name, 16 | Image: image, 17 | Ports: &node.Ports{}, 18 | } 19 | 20 | for _, p := range ports { 21 | rv.Ports.Add("", p) 22 | } 23 | 24 | return rv 25 | } 26 | 27 | func TestNodeIsExternal(t *testing.T) { 28 | t.Parallel() 29 | 30 | n := node.Node{} 31 | 32 | if !n.IsExternal() { 33 | t.Fail() 34 | } 35 | 36 | n.ID = "id" 37 | 38 | if n.IsExternal() { 39 | t.Fail() 40 | } 41 | } 42 | 43 | func TestNodeToJSON(t *testing.T) { 44 | t.Parallel() 45 | 46 | nodeMeta := makeTestNode("test-id", "test-name", "test-image", []*node.Port{ 47 | {Kind: "udp", Value: "53"}, 48 | }) 49 | 50 | nodeMeta.Meta = &node.Meta{ 51 | Info: "test", 52 | Tags: []string{"test"}, 53 | } 54 | 55 | nodeContainer := makeTestNode("test-id2", "test-name", "test-image", []*node.Port{ 56 | {Kind: "udp", Value: "53"}, 57 | }) 58 | 59 | nodeContainer.Container.Cmd = []string{"foo"} 60 | nodeContainer.Container.Env = []string{"A=B"} 61 | 62 | testCases := []struct { 63 | Node *node.Node 64 | Name string 65 | Image string 66 | Volumes int 67 | External bool 68 | HasMeta bool 69 | HasProcess bool 70 | }{ 71 | { 72 | Node: makeTestNode("test-id", "test-name1", "", []*node.Port{ 73 | {Kind: "tcp", Value: "80"}, 74 | {Kind: "udp", Value: "53"}, 75 | }), 76 | Name: "test-name1", 77 | }, 78 | { 79 | Node: makeTestNode("test-id", "test-id", "", []*node.Port{ 80 | {Kind: "tcp", Value: "80"}, 81 | }), 82 | Name: "test-id", 83 | External: true, 84 | }, 85 | { 86 | Node: makeTestNode("test-id3", "test-name", "test-image", []*node.Port{ 87 | {Kind: "udp", Value: "53"}, 88 | }), 89 | Name: "test-name", 90 | Image: "test-image", 91 | }, 92 | { 93 | Node: nodeMeta, 94 | Name: "test-name", 95 | Image: "test-image", 96 | HasMeta: true, 97 | }, 98 | { 99 | Node: nodeContainer, 100 | Name: "test-name", 101 | Image: "test-image", 102 | HasProcess: true, 103 | }, 104 | { 105 | Node: &node.Node{ 106 | ID: "test-id", 107 | Name: "test-name", 108 | Image: "test-image", 109 | Ports: &node.Ports{}, 110 | Volumes: []*node.Volume{ 111 | {Type: "none"}, 112 | {Type: "bind"}, 113 | }, 114 | }, 115 | Name: "test-name", 116 | Image: "test-image", 117 | Volumes: 2, 118 | }, 119 | } 120 | 121 | for _, tc := range testCases { 122 | j := tc.Node.ToJSON() 123 | 124 | if j.Name != tc.Name { 125 | t.Fatal("name", tc) 126 | } 127 | 128 | if j.IsExternal != tc.External { 129 | t.Fatal("external", tc) 130 | } 131 | 132 | if tc.HasMeta && len(j.Tags) == 0 { 133 | t.Fatal("extra", tc) 134 | } 135 | 136 | if tc.HasProcess && len(j.Container.Cmd) == 0 { 137 | t.Fatal("process", tc) 138 | } 139 | 140 | if len(j.Volumes) != tc.Volumes { 141 | t.Fatal("volumes", tc) 142 | } 143 | 144 | if tc.Image == "" { 145 | continue 146 | } 147 | 148 | if j.Image == nil { 149 | t.Fatal("image == nil", tc) 150 | } 151 | 152 | if *j.Image != tc.Image { 153 | t.Fatal("image", tc) 154 | } 155 | } 156 | } 157 | 158 | func TestNodeToView(t *testing.T) { 159 | t.Parallel() 160 | 161 | nodeContainer := makeTestNode("test-id", "test-name", "test-image", []*node.Port{ 162 | {Kind: "udp", Value: "53"}, 163 | }) 164 | 165 | nodeContainer.Container.Cmd = []string{"foo", "-arg"} 166 | 167 | testCases := []struct { 168 | Node *node.Node 169 | Cmd string 170 | Tags int 171 | Args int 172 | External bool 173 | }{ 174 | { 175 | Node: &node.Node{ 176 | Name: "test", 177 | Image: "image", 178 | }, 179 | External: false, 180 | Tags: 0, 181 | Cmd: "", 182 | Args: 0, 183 | }, 184 | { 185 | Node: &node.Node{ 186 | Name: "test", 187 | Image: "image", 188 | Meta: &node.Meta{ 189 | Info: "", 190 | }, 191 | }, 192 | External: false, 193 | Tags: 0, 194 | Cmd: "", 195 | Args: 0, 196 | }, 197 | { 198 | Node: &node.Node{ 199 | Name: "test", 200 | Image: "image", 201 | Meta: &node.Meta{ 202 | Info: "", 203 | Tags: []string{"a"}, 204 | }, 205 | }, 206 | External: false, 207 | Tags: 1, 208 | Cmd: "", 209 | Args: 0, 210 | }, 211 | { 212 | Node: &node.Node{ 213 | ID: "test", 214 | Name: "test", 215 | }, 216 | External: true, 217 | Tags: 0, 218 | Cmd: "", 219 | Args: 0, 220 | }, 221 | { 222 | Node: nodeContainer, 223 | External: false, 224 | Tags: 0, 225 | Cmd: "foo", 226 | Args: 1, 227 | }, 228 | } 229 | 230 | for _, tc := range testCases { 231 | v := tc.Node.ToView() 232 | 233 | if v.Name != tc.Node.Name { 234 | t.Fail() 235 | } 236 | 237 | if v.Image != tc.Node.Image { 238 | t.Fail() 239 | } 240 | 241 | if v.IsExternal != tc.External { 242 | t.Fail() 243 | } 244 | 245 | if v.Cmd != tc.Cmd { 246 | t.Fail() 247 | } 248 | 249 | if len(v.Tags) != tc.Tags { 250 | t.Fail() 251 | } 252 | 253 | if len(v.Args) != tc.Args { 254 | t.Fail() 255 | } 256 | } 257 | } 258 | 259 | func TestNodeFormatMeta(t *testing.T) { 260 | t.Parallel() 261 | 262 | n := node.Node{} 263 | 264 | if _, ok := n.FormatMeta(); ok { 265 | t.Fail() 266 | } 267 | 268 | n.Meta = &node.Meta{ 269 | Info: "foo", 270 | Docs: "bar", 271 | Repo: "baz", 272 | Tags: []string{"a", "b"}, 273 | } 274 | 275 | if _, ok := n.FormatMeta(); !ok { 276 | t.Fail() 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /internal/node/port.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | type portJSON struct { 10 | Kind string `json:"kind"` 11 | Value string `json:"value"` 12 | Local bool `json:"local"` 13 | } 14 | 15 | type Port struct { 16 | Kind string `json:"kind"` 17 | Value string `json:"value"` 18 | Number int `json:"-"` 19 | Local bool `json:"local"` 20 | } 21 | 22 | func (p *Port) Label() string { 23 | return p.Kind + ":" + p.Value 24 | } 25 | 26 | func (p *Port) Equal(v *Port) (yes bool) { 27 | return p.Kind == v.Kind && 28 | p.Value == v.Value 29 | } 30 | 31 | func (p *Port) UnmarshalJSON(b []byte) (err error) { 32 | var v portJSON 33 | 34 | if err = json.Unmarshal(b, &v); err != nil { 35 | return fmt.Errorf("unmarshal json: %w", err) 36 | } 37 | 38 | const sUNIX = "unix" 39 | 40 | if v.Kind != sUNIX { 41 | if p.Number, err = strconv.Atoi(v.Value); err != nil { 42 | return fmt.Errorf("invalid port: '%s' atoi: %w", v.Value, err) 43 | } 44 | } 45 | 46 | p.Kind = v.Kind 47 | p.Value = v.Value 48 | p.Local = v.Local 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/node/port_test.go: -------------------------------------------------------------------------------- 1 | package node_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/s0rg/decompose/internal/node" 8 | ) 9 | 10 | func makeTestPorts(vals ...*node.Port) (rv *node.Ports) { 11 | rv = &node.Ports{} 12 | 13 | for _, p := range vals { 14 | rv.Add("", p) 15 | } 16 | 17 | return rv 18 | } 19 | 20 | func TestPortLabel(t *testing.T) { 21 | t.Parallel() 22 | 23 | const want = "100" 24 | 25 | p := node.Port{Value: "100", Kind: "tcp"} 26 | l := p.Label() 27 | 28 | if !strings.HasPrefix(l, p.Kind) { 29 | t.Fail() 30 | } 31 | 32 | if !strings.HasSuffix(l, want) { 33 | t.Fail() 34 | } 35 | } 36 | 37 | func TestPortsHas(t *testing.T) { 38 | t.Parallel() 39 | 40 | testCases := []struct { 41 | Ports *node.Ports 42 | Labels []string 43 | Want bool 44 | }{ 45 | { 46 | Ports: &node.Ports{}, 47 | Labels: []string{}, 48 | Want: false, 49 | }, 50 | { 51 | Ports: &node.Ports{}, 52 | Labels: []string{"tcp:80"}, 53 | Want: false, 54 | }, 55 | { 56 | Ports: makeTestPorts(&node.Port{ 57 | Kind: "tcp", 58 | Value: "80", 59 | }), 60 | Labels: []string{"tcp:80"}, 61 | Want: true, 62 | }, 63 | { 64 | Ports: makeTestPorts(&node.Port{ 65 | Kind: "tcp", 66 | Value: "81", 67 | }), 68 | Labels: []string{"tcp:80"}, 69 | Want: false, 70 | }, 71 | { 72 | Ports: makeTestPorts(&node.Port{ 73 | Kind: "tcp", 74 | Value: "80", 75 | }), 76 | Labels: []string{"tcp:80", "tcp:443"}, 77 | Want: false, 78 | }, 79 | } 80 | 81 | for _, tc := range testCases { 82 | if tc.Ports.Has(tc.Labels...) != tc.Want { 83 | t.Fail() 84 | } 85 | } 86 | } 87 | 88 | func TestPortsHasAny(t *testing.T) { 89 | t.Parallel() 90 | 91 | testCases := []struct { 92 | Ports *node.Ports 93 | Labels []string 94 | Want bool 95 | }{ 96 | { 97 | Ports: &node.Ports{}, 98 | Labels: []string{}, 99 | Want: false, 100 | }, 101 | { 102 | Ports: &node.Ports{}, 103 | Labels: []string{"tcp:80"}, 104 | Want: false, 105 | }, 106 | { 107 | Ports: makeTestPorts(&node.Port{ 108 | Kind: "tcp", 109 | Value: "80", 110 | }), 111 | Labels: []string{"tcp:80"}, 112 | Want: true, 113 | }, 114 | { 115 | Ports: makeTestPorts(&node.Port{ 116 | Kind: "tcp", 117 | Value: "81", 118 | }), 119 | Labels: []string{"tcp:80"}, 120 | Want: false, 121 | }, 122 | { 123 | Ports: makeTestPorts(&node.Port{ 124 | Kind: "tcp", 125 | Value: "80", 126 | }), 127 | Labels: []string{"tcp:80", "tcp:443"}, 128 | Want: true, 129 | }, 130 | } 131 | 132 | for _, tc := range testCases { 133 | if tc.Ports.HasAny(tc.Labels...) != tc.Want { 134 | t.Fail() 135 | } 136 | } 137 | } 138 | 139 | func TestPortJSON(t *testing.T) { 140 | t.Parallel() 141 | 142 | testCases := []struct { 143 | JSON []byte 144 | Ok bool 145 | Number int 146 | }{ 147 | {[]byte(`{`), false, 0}, 148 | {[]byte(`{"value": "string"}`), false, 0}, 149 | {[]byte(`{"kind": "tcp", "value": "80"}`), true, 80}, 150 | } 151 | 152 | for _, tc := range testCases { 153 | var p node.Port 154 | 155 | err := p.UnmarshalJSON(tc.JSON) 156 | 157 | switch { 158 | case err != nil && !tc.Ok: 159 | case err == nil && tc.Ok: 160 | default: 161 | t.FailNow() 162 | } 163 | 164 | if p.Number != tc.Number { 165 | t.FailNow() 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/node/ports.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "cmp" 5 | "slices" 6 | 7 | "github.com/s0rg/set" 8 | ) 9 | 10 | type Ports struct { 11 | ports map[string][]*Port 12 | order []string 13 | } 14 | 15 | func (ps *Ports) Add(process string, p *Port) { 16 | if ps.ports == nil { 17 | ps.ports = make(map[string][]*Port) 18 | } 19 | 20 | ports, ok := ps.ports[process] 21 | 22 | ps.ports[process] = append(ports, p) 23 | 24 | if !ok { 25 | ps.order = append(ps.order, process) 26 | slices.Sort(ps.order) 27 | } 28 | } 29 | 30 | func (ps *Ports) Join(other *Ports) { 31 | other.Iter(func(process string, ports []*Port) { 32 | for _, p := range ports { 33 | ps.Add(process, p) 34 | } 35 | }) 36 | } 37 | 38 | func (ps *Ports) Get(p *Port) (name string, ok bool) { 39 | for name, pl := range ps.ports { 40 | for _, l := range pl { 41 | if l.Equal(p) { 42 | return name, true 43 | } 44 | } 45 | } 46 | 47 | return 48 | } 49 | 50 | func (ps *Ports) Iter(it func(process string, p []*Port)) { 51 | for _, name := range ps.order { 52 | it(name, ps.ports[name]) 53 | } 54 | } 55 | 56 | func (ps *Ports) Len() (rv int) { 57 | for _, pl := range ps.ports { 58 | rv += len(pl) 59 | } 60 | 61 | return rv 62 | } 63 | 64 | func (ps *Ports) Compact() { 65 | for k, pl := range ps.ports { 66 | slices.SortFunc(pl, func(a, b *Port) int { 67 | if a.Kind == b.Kind { 68 | return cmp.Compare(a.Value, b.Value) 69 | } 70 | 71 | return cmp.Compare(a.Kind, b.Kind) 72 | }) 73 | 74 | ps.ports[k] = slices.CompactFunc(pl, func(a, b *Port) bool { 75 | return a.Equal(b) 76 | }) 77 | } 78 | } 79 | 80 | func (ps *Ports) HasAny(label ...string) (yes bool) { 81 | if len(ps.ports) == 0 { 82 | return 83 | } 84 | 85 | s := make(set.Unordered[string]) 86 | set.Load(s, label...) 87 | 88 | for _, ports := range ps.ports { 89 | for _, p := range ports { 90 | if s.Has(p.Label()) { 91 | return true 92 | } 93 | } 94 | } 95 | 96 | return false 97 | } 98 | 99 | func (ps *Ports) Has(label ...string) (yes bool) { 100 | if len(ps.ports) == 0 { 101 | return 102 | } 103 | 104 | s := make(set.Unordered[string]) 105 | set.Load(s, label...) 106 | 107 | for _, ports := range ps.ports { 108 | for _, p := range ports { 109 | s.Del(p.Label()) 110 | } 111 | } 112 | 113 | return s.Len() == 0 114 | } 115 | -------------------------------------------------------------------------------- /internal/node/ports_test.go: -------------------------------------------------------------------------------- 1 | package node_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/s0rg/decompose/internal/node" 7 | ) 8 | 9 | func TestPortsGet(t *testing.T) { 10 | t.Parallel() 11 | 12 | ps := &node.Ports{} 13 | 14 | if ps.Len() > 0 { 15 | t.Fail() 16 | } 17 | 18 | port := &node.Port{Kind: "tcp", Value: "1"} 19 | 20 | ps.Add("foo", port) 21 | 22 | if ps.Len() != 1 { 23 | t.Fail() 24 | } 25 | 26 | val, ok := ps.Get(port) 27 | if !ok || val != "foo" { 28 | t.Fail() 29 | } 30 | 31 | if _, ok = ps.Get(&node.Port{}); ok { 32 | t.Fail() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/node/view.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | type PortMatcher interface { 4 | HasAny(...string) bool 5 | Has(...string) bool 6 | } 7 | 8 | type View struct { 9 | Listen PortMatcher 10 | Name string 11 | Image string 12 | Cmd string 13 | Args []string 14 | Tags []string 15 | IsExternal bool 16 | } 17 | -------------------------------------------------------------------------------- /internal/structurizr/component.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | import "io" 4 | 5 | type Component struct { 6 | ID string 7 | Name string 8 | Description string 9 | Technology string 10 | Tags []string 11 | } 12 | 13 | func (c *Component) Write(w io.Writer, level int) { 14 | putCommon(w, level, c.Description, c.Technology, c.Tags) 15 | } 16 | -------------------------------------------------------------------------------- /internal/structurizr/const.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | const ( 4 | keyDescription = "description" 5 | keyTechnology = "technology" 6 | keyTags = "tags" 7 | keyName = "name" 8 | 9 | blockContainer = "container" 10 | blockComponent = "component" 11 | blockSystemCtx = "systemContext" 12 | blockSystem = "softwareSystem" 13 | 14 | headerWorkspace = "workspace" 15 | headerModel = "model" 16 | headerViews = "views" 17 | ) 18 | -------------------------------------------------------------------------------- /internal/structurizr/container.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type Container struct { 8 | ID string 9 | Name string 10 | Description string 11 | Technology string 12 | Tags []string 13 | Components []*Component 14 | } 15 | 16 | func (c *Container) Write(w io.Writer, level int) { 17 | putCommon(w, level, c.Description, c.Technology, c.Tags) 18 | 19 | for _, com := range c.Components { 20 | putBlock(w, level, blockComponent, com.ID, com.Name) 21 | com.Write(w, level+1) 22 | putEnd(w, level) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/structurizr/relation.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | type Relation struct { 4 | Src string 5 | Dst string 6 | Tags []string 7 | } 8 | -------------------------------------------------------------------------------- /internal/structurizr/system.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | import ( 4 | "io" 5 | "slices" 6 | ) 7 | 8 | type System struct { 9 | containers map[string]*Container 10 | relationships map[string]map[string]*Relation 11 | ID string 12 | Name string 13 | Description string 14 | Tags []string 15 | order []string 16 | } 17 | 18 | func NewSystem(name string) *System { 19 | return &System{ 20 | ID: SafeID(name), 21 | Name: name, 22 | containers: make(map[string]*Container), 23 | relationships: make(map[string]map[string]*Relation), 24 | } 25 | } 26 | 27 | func (s *System) AddContainer(id, name string) (c *Container, ok bool) { 28 | id = SafeID(id) 29 | 30 | if _, ok = s.containers[id]; ok { 31 | return nil, false 32 | } 33 | 34 | c = &Container{ 35 | ID: id, 36 | Name: name, 37 | } 38 | 39 | s.containers[id] = c 40 | s.order = append(s.order, id) 41 | slices.Sort(s.order) 42 | 43 | return c, true 44 | } 45 | 46 | func (s *System) findRelation(src, dst string) (rv *Relation, found bool) { 47 | src, dst = SafeID(src), SafeID(dst) 48 | 49 | if dest, ok := s.relationships[src]; ok { 50 | if rel, ok := dest[dst]; ok { 51 | return rel, true 52 | } 53 | } 54 | 55 | if dest, ok := s.relationships[dst]; ok { 56 | if rel, ok := dest[src]; ok { 57 | return rel, true 58 | } 59 | } 60 | 61 | return nil, false 62 | } 63 | 64 | func (s *System) AddRelation(srcID, dstID, srcName, dstName string) (rv *Relation, ok bool) { 65 | src, ok := s.containers[SafeID(srcID)] 66 | if !ok { 67 | return nil, false 68 | } 69 | 70 | dst, ok := s.containers[SafeID(dstID)] 71 | if !ok { 72 | return nil, false 73 | } 74 | 75 | if rv, ok = s.findRelation(src.ID, dst.ID); ok { 76 | return rv, true 77 | } 78 | 79 | srcID, dstID = SafeID(src.ID), SafeID(dst.ID) 80 | 81 | dest, ok := s.relationships[srcID] 82 | if !ok { 83 | dest = make(map[string]*Relation) 84 | } 85 | 86 | rv, ok = dest[dstID] 87 | if !ok { 88 | rv = &Relation{ 89 | Src: srcName, 90 | Dst: dstName, 91 | } 92 | } 93 | 94 | dest[dstID] = rv 95 | 96 | s.relationships[srcID] = dest 97 | 98 | return rv, true 99 | } 100 | 101 | func (s *System) WriteContainers(w io.Writer, level int) { 102 | putCommon(w, level, s.Description, "", s.Tags) 103 | 104 | next := level + 1 105 | 106 | for _, cID := range s.order { 107 | cont := s.containers[cID] 108 | 109 | putBlock(w, next, blockContainer, cont.ID, cont.Name) 110 | cont.Write(w, next+1) 111 | putEnd(w, next) 112 | } 113 | } 114 | 115 | func (s *System) WriteViews(w io.Writer, level int) { 116 | next := level + 1 117 | 118 | for _, cID := range s.order { 119 | cont := s.containers[cID] 120 | 121 | putView(w, level, blockComponent, cont.ID) 122 | 123 | putRaw(w, next, "include *") 124 | putRaw(w, next, "autoLayout") 125 | 126 | putEnd(w, level) 127 | } 128 | } 129 | 130 | func (s *System) WriteRelations(w io.Writer, level int) { 131 | for srcID, dest := range s.relationships { 132 | for dstID, rel := range dest { 133 | putRelation(w, level, srcID, dstID, rel.Tags) 134 | putEnd(w, level) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /internal/structurizr/system_test.go: -------------------------------------------------------------------------------- 1 | package srtructurizr_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | srtructurizr "github.com/s0rg/decompose/internal/structurizr" 9 | ) 10 | 11 | func TestSystemRelation(t *testing.T) { 12 | t.Parallel() 13 | 14 | s := srtructurizr.NewSystem("") 15 | s.AddContainer("id1", "name1") 16 | s.AddContainer("id2", "name2") 17 | 18 | if _, ok := s.AddRelation("id1", "id2", "id1", "id2"); !ok { 19 | t.Fail() 20 | } 21 | 22 | if _, ok := s.AddRelation("id2", "id1", "id2", "id1"); !ok { 23 | t.Fail() 24 | } 25 | 26 | if _, ok := s.AddRelation("id2", "id1", "id2", "id1"); !ok { 27 | t.Fail() 28 | } 29 | 30 | if _, ok := s.AddRelation("id1", "id2", "id1", "id2"); !ok { 31 | t.Fail() 32 | } 33 | 34 | if _, ok := s.AddRelation("id1", "id3", "id1", "id3"); ok { 35 | t.Fail() 36 | } 37 | 38 | if _, ok := s.AddRelation("id3", "id1", "id3", "id1"); ok { 39 | t.Fail() 40 | } 41 | 42 | var b bytes.Buffer 43 | 44 | s.WriteRelations(&b, 0) 45 | 46 | if strings.Count(b.String(), "id1 -> id2") != 1 { 47 | t.Fail() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/structurizr/utils.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "slices" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | const tab = "\t" 12 | 13 | func putKey( 14 | w io.Writer, 15 | level int, 16 | key, value string, 17 | ) { 18 | if value == "" { 19 | return 20 | } 21 | 22 | fmt.Fprint(w, strings.Repeat(tab, level)) 23 | fmt.Fprintf(w, `%s "%s"`, key, value) 24 | fmt.Fprintln(w, "") 25 | } 26 | 27 | func putView( 28 | w io.Writer, 29 | level int, 30 | key, id string, 31 | ) { 32 | fmt.Fprint(w, strings.Repeat(tab, level)) 33 | fmt.Fprintf(w, `%s %s "%s" {`, key, id, key+"_"+id) 34 | fmt.Fprintln(w, "") 35 | } 36 | 37 | func putRaw( 38 | w io.Writer, 39 | level int, 40 | raw string, 41 | ) { 42 | fmt.Fprint(w, strings.Repeat(tab, level)) 43 | fmt.Fprintln(w, raw) 44 | } 45 | 46 | func putHeader( 47 | w io.Writer, 48 | level int, 49 | key string, 50 | ) { 51 | fmt.Fprint(w, strings.Repeat(tab, level)) 52 | fmt.Fprintf(w, "%s {\n", key) 53 | } 54 | 55 | func putCommon( 56 | w io.Writer, 57 | level int, 58 | desc, tech string, 59 | tags []string, 60 | ) { 61 | if desc != "" { 62 | putKey(w, level, keyDescription, desc) 63 | } 64 | 65 | if tech != "" { 66 | putKey(w, level, keyTechnology, tech) 67 | } 68 | 69 | if ctags, ok := compactTags(tags); ok { 70 | putKey(w, level, keyTags, strings.Join(ctags, ",")) 71 | } 72 | } 73 | 74 | func putBlock( 75 | w io.Writer, 76 | level int, 77 | block, key, value string, 78 | ) { 79 | fmt.Fprint(w, strings.Repeat(tab, level)) 80 | fmt.Fprintf(w, `%s = %s "%s" {`, key, block, value) 81 | fmt.Fprintln(w, "") 82 | } 83 | 84 | func putRelation( 85 | w io.Writer, 86 | level int, 87 | src, dst string, 88 | tags []string, 89 | ) { 90 | fmt.Fprint(w, strings.Repeat(tab, level)) 91 | fmt.Fprintf(w, "%s -> %s \"%s\" {\n", src, dst, strings.Join(tags, ",")) 92 | } 93 | 94 | func putEnd(w io.Writer, level int) { 95 | fmt.Fprint(w, strings.Repeat(tab, level)) 96 | fmt.Fprintln(w, "}") 97 | } 98 | 99 | func SafeID(v string) (id string) { 100 | return strings.Map(func(r rune) rune { 101 | switch { 102 | case unicode.IsSpace(r), unicode.IsPunct(r): 103 | return '_' 104 | } 105 | 106 | return r 107 | }, v) 108 | } 109 | 110 | func compactTags(tags []string) (rv []string, ok bool) { 111 | if len(tags) == 0 { 112 | return 113 | } 114 | 115 | rv = slices.DeleteFunc(tags, func(v string) bool { 116 | return strings.TrimSpace(v) == "" 117 | }) 118 | 119 | switch len(rv) { 120 | case 0: 121 | return nil, false 122 | case 1: 123 | return rv, true 124 | default: 125 | slices.Sort(rv) 126 | 127 | return slices.Clip(slices.Compact(rv)), true 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /internal/structurizr/workspace.go: -------------------------------------------------------------------------------- 1 | package srtructurizr 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "io" 7 | "slices" 8 | ) 9 | 10 | type Workspace struct { 11 | relationships map[string]map[string]*Relation 12 | systems map[string]*System 13 | Name string 14 | Description string 15 | defaultSystem string 16 | systemsOrder []string 17 | } 18 | 19 | func NewWorkspace(name, defaultSystem string) *Workspace { 20 | return &Workspace{ 21 | Name: name, 22 | defaultSystem: defaultSystem, 23 | systemsOrder: []string{SafeID(defaultSystem)}, 24 | systems: make(map[string]*System), 25 | relationships: make(map[string]map[string]*Relation), 26 | } 27 | } 28 | 29 | func (ws *Workspace) System(name string) (rv *System) { 30 | id := SafeID(name) 31 | 32 | if sys, ok := ws.systems[id]; ok { 33 | return sys 34 | } 35 | 36 | rv = NewSystem(name) 37 | 38 | ws.systems[rv.ID] = rv 39 | 40 | if rv.ID != ws.defaultSystem { 41 | ws.systemsOrder = append(ws.systemsOrder, rv.ID) 42 | } 43 | 44 | return rv 45 | } 46 | 47 | func (ws *Workspace) HasSystem(name string) (yes bool) { 48 | _, yes = ws.systems[SafeID(name)] 49 | 50 | return 51 | } 52 | 53 | func (ws *Workspace) AddRelation(srcID, dstID, srcName, dstName string) (rv *Relation, ok bool) { 54 | srcID, dstID = SafeID(srcID), SafeID(dstID) 55 | 56 | if !ws.HasSystem(srcID) || !ws.HasSystem(dstID) { 57 | return 58 | } 59 | 60 | dmap, ok := ws.relationships[srcID] 61 | if !ok { 62 | dmap = make(map[string]*Relation) 63 | ws.relationships[srcID] = dmap 64 | } 65 | 66 | if rv, ok = dmap[dstID]; ok { 67 | return rv, ok 68 | } 69 | 70 | rv = &Relation{ 71 | Src: srcName, 72 | Dst: dstName, 73 | } 74 | 75 | dmap[dstID] = rv 76 | 77 | return rv, true 78 | } 79 | 80 | func (ws *Workspace) Write(w io.Writer) { 81 | var level int 82 | 83 | slices.Sort(ws.systemsOrder[1:]) 84 | 85 | putHeader(w, level, headerWorkspace) 86 | 87 | level++ 88 | putKey(w, level, keyName, ws.Name) 89 | putKey(w, level, keyDescription, ws.Description) 90 | 91 | fmt.Fprintln(w, "") 92 | putHeader(w, level, headerModel) 93 | 94 | level++ 95 | 96 | for _, key := range ws.systemsOrder { 97 | system := ws.systems[key] 98 | 99 | putBlock(w, level, blockSystem, system.ID, system.Name) 100 | system.WriteContainers(w, level) 101 | putEnd(w, level) 102 | } 103 | 104 | fmt.Fprintln(w, "") 105 | 106 | for _, key := range ws.systemsOrder { 107 | system := ws.systems[key] 108 | 109 | system.WriteRelations(w, level) 110 | } 111 | 112 | ws.writeRelations(w, level) 113 | 114 | level-- 115 | putEnd(w, level) // model 116 | 117 | fmt.Fprintln(w, "") 118 | 119 | ws.writeViews(w, level) 120 | 121 | level-- 122 | putEnd(w, level) // workspace 123 | } 124 | 125 | func (ws *Workspace) writeRelations(w io.Writer, level int) { 126 | relOrder := make([]string, 0, len(ws.relationships)) 127 | 128 | for srcID := range ws.relationships { 129 | relOrder = append(relOrder, srcID) 130 | } 131 | 132 | slices.Sort(relOrder) 133 | 134 | for _, srcID := range relOrder { 135 | dest := ws.relationships[srcID] 136 | 137 | dstOrder := make([]string, 0, len(dest)) 138 | 139 | for dstID := range dest { 140 | dstOrder = append(dstOrder, dstID) 141 | } 142 | 143 | slices.SortFunc(dstOrder, cmp.Compare) 144 | 145 | for _, dstID := range dstOrder { 146 | rel := dest[dstID] 147 | 148 | putRelation(w, level, srcID, dstID, rel.Tags) 149 | putEnd(w, level) 150 | } 151 | } 152 | } 153 | 154 | func (ws *Workspace) writeDefaultIncludes(w io.Writer, level int) { 155 | for _, id := range ws.systemsOrder { 156 | if id == ws.defaultSystem { 157 | continue 158 | } 159 | 160 | putRaw(w, level, "include "+id) 161 | } 162 | } 163 | 164 | func (ws *Workspace) writeViews(w io.Writer, level int) { 165 | putHeader(w, level, headerViews) 166 | 167 | level++ 168 | 169 | for _, key := range ws.systemsOrder { 170 | system := ws.systems[key] 171 | 172 | putView(w, level, blockSystemCtx, system.ID) 173 | 174 | level++ 175 | 176 | putRaw(w, level, "include *") 177 | 178 | if system.Name == ws.defaultSystem { 179 | ws.writeDefaultIncludes(w, level) 180 | } 181 | 182 | putRaw(w, level, "autoLayout") 183 | 184 | level-- 185 | 186 | putEnd(w, level) // system context 187 | 188 | putView(w, level, blockContainer, system.ID) 189 | 190 | level++ 191 | 192 | putRaw(w, level, "include *") 193 | putRaw(w, level, "autoLayout") 194 | 195 | level-- 196 | 197 | putEnd(w, level) // container 198 | 199 | system.WriteViews(w, level) 200 | } 201 | 202 | fmt.Fprintln(w, "") 203 | putHeader(w, level, "styles") 204 | 205 | level++ 206 | 207 | putRaw(w, level, `element "Element" {`) 208 | 209 | level++ 210 | 211 | putRaw(w, level, "metadata true") 212 | putRaw(w, level, "description true") 213 | 214 | level-- 215 | 216 | putEnd(w, level) // element 217 | 218 | level-- 219 | 220 | putEnd(w, level) // styles 221 | 222 | level-- 223 | 224 | putEnd(w, level) // views 225 | } 226 | -------------------------------------------------------------------------------- /internal/structurizr/workspace_test.go: -------------------------------------------------------------------------------- 1 | package srtructurizr_test 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | srtructurizr "github.com/s0rg/decompose/internal/structurizr" 9 | ) 10 | 11 | func TestWorkspaceRelation(t *testing.T) { 12 | t.Parallel() 13 | 14 | ws := srtructurizr.NewWorkspace("test", "1") 15 | 16 | if _, ok := ws.AddRelation("foo", "bar", "foo", "bar"); ok { 17 | t.Fail() 18 | } 19 | 20 | ws.System("1") 21 | s := ws.System("2") 22 | 23 | s.Tags = append(s.Tags, "") 24 | 25 | if _, ok := ws.AddRelation("1", "2", "1", "2"); !ok { 26 | t.Fail() 27 | } 28 | 29 | if _, ok := ws.AddRelation("2", "1", "2", "1"); !ok { 30 | t.Fail() 31 | } 32 | 33 | if _, ok := ws.AddRelation("1", "2", "1", "2"); !ok { 34 | t.Fail() 35 | } 36 | 37 | var b bytes.Buffer 38 | 39 | ws.Write(&b) 40 | 41 | if strings.Contains(b.String(), "tags") { 42 | t.Fail() 43 | } 44 | } 45 | --------------------------------------------------------------------------------