diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9fd184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +docker.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1c5bcae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php + +php: + - 7.0 + - nightly + +env: + matrix: + - COMPOSERFLAGS="--prefer-stable" + - COMPOSERFLAGS="--prefer-lowest" + +matrix: + allow_failures: + - php: nightly + +script: + - make test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee930f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM php:7-alpine + +RUN apk update && \ + apk add graphviz ttf-dejavu && \ + rm -rf \ + /var/cache/apk/* \ + /tmp/* + +COPY bin/ /dcv/bin +COPY src/ /dcv/src +COPY vendor/ /dcv/vendor + +RUN addgroup dcv && \ + adduser -D -G dcv -s /bin/bash -g "docker-compose-viz" -h /input dcv + +USER dcv +VOLUME /input +WORKDIR /input + +ENTRYPOINT ["/dcv/bin/dcv"] +CMD ["render", "-m", "image", "-f"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b99e3c --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +The MIT License (MIT) +Copyright (c) 2016 PMSIpilot + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e9a7d2 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +DCV_IMAGE_NAME=pmsipilot/docker-compose-viz + +COMPOSER ?= composer +COMPOSERFLAGS ?= +DOCKER ?= docker +PHP ?= php + +.PHONY: clean docker test + +docker: docker.lock + +test: vendor + $(PHP) bin/kahlan --pattern='*.php' --reporter=verbose + +clean: + rm -rf vendor/ + +docker.lock: Dockerfile vendor + $(COMPOSER) dump-autoload --classmap-authoritative + $(DOCKER) build -t $(DCV_IMAGE_NAME) . + touch docker.lock + +ifndef COMPOSERFLAGS +vendor: composer.lock + $(COMPOSER) install --prefer-dist +else +vendor: composer.lock + $(COMPOSER) update $(COMPOSERFLAGS) +endif + +composer.lock: composer.json + $(COMPOSER) update $(COMPOSERFLAGS) diff --git a/README.md b/README.md new file mode 100644 index 0000000..25be6ea --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# `docker-compose-viz` + +[![Build Status](https://travis-ci.org/pmsipilot/docker-compose-viz.svg?branch=master)](https://travis-ci.org/pmsipilot/docker-compose-viz) + +## How to use + +Before you start, make sure you have: + +* [Composer](https://getcomposer.org/doc/00-intro.md#installation-linux-unix-osx) installed, +* [PHP 7](http://php.net/downloads.php#v7.0.9) installed, +* GraphViz installed (see below for a guide on how to install it) + +### Docker + +``` +docker run --rm -it --name dcv -v $(pwd):/input pmsipilot/docker-compose-viz +``` + +### PHP + +``` +git clone https://github.com/pmsipilot/docker-compose-viz.git + +make vendor +# Or +composer install --prefer-dist + +bin/dcv +``` + +### Install GraphViz + +* On MacOS: `brew install graphviz` +* On Debian: `sudo apt-get install graphviz` + +## Usage + +``` +render [options] [--] [] + +Arguments: + input-file Path to a docker compose file [default: "./docker-compose.yml"] + +Options: + -o, --output-file=OUTPUT-FILE Path to a output file (Only for "dot" and "image" output format) [default: "./docker-compose.dot" or "./docker-compose.png"] + -m, --output-format=OUTPUT-FORMAT Output format (one of: "dot", "image", "display") [default: "display"] + --only=ONLY Display a graph only for a given services (multiple values allowed) + -f, --force Overwrites output file if it already exists + --no-volumes Do not display volumes + -r, --horizontal Display a horizontal graph +``` + +## How to read the graph + +### Links + +Links (from `services..links`) are displayed as plain arrows pointing to the service that declares the link: + +![links](resources/links.png) + +If we look at the link between `mysql` and `ambassador`, it reads as follow: "`mysql` is known as `mysql` in `ambassador`." +If we look at the link between `ambassador` and `logs`, it reads as follow: "`ambassador` is known as `logstash` in `logs`." + +### Volumes + +Volumes (from `services..volumes_from`) are displayed as dashed arrows pointing to the service that uses the volumes: + +![volumes](resources/volumes.png) + +If we look at the link between `logs` and `api`, it reads as follow: "`api` uses volumes from `logs`." + +Volumes (from `services..volumes`) are displayed as folders with the host directory as label and are linked to the service that uses them dashed arrows. + +If we look at the link between `./api` and `api`, it reads as follow: "the host directory `./api`is mounted as a read-write folder on `/src` in `api`." Bidirectional arrows mean the directory is writable from the container. + +If we look at the link between `./etc/api/php-fpm.d` and `api`, it reads as follow: "the host directory `./etc/api/php-fpm.d`is mounted as a read-only folder on `/usr/local/etc/php-fpm.d` in `api`." Unidirectional arrows mean the directory is not writable from the container. + +### Dependencies + +Dependencies (from `services..depends_on`) are displayed as dotted arrows pointing to the service that declares the dependencies: + +![dependencies](resources/dependencies.png) + +If we look at the link between `mysql` and `logs`, it reads as follow: "`mysql` depends on `logs`." + +### Ports + +Ports (from `services..ports`) are displayed as circle and are linked to containers using plain arrows pointing to the service that declares the ports: + +![ports](resources/ports.png) + +If we look at the link between port `2480` and `orientdb`, it reads as follow: "traffic coming to host port `2480` will be routed to port `2480` of `orientdb`." +If we look at the link between port `2580` and `elk`, it reads as follow: "traffix coming to host port `2580` will be routed to port `80` of `elk`." + +## Examples + +### `dot` renderer + +```dot +digraph G { + graph [pad=0.5] + "front" [shape="component"] + "http" [shape="component"] + 2380 [shape="circle"] + "ambassador" [shape="component"] + "mysql" [shape="component"] + "orientdb" [shape="component"] + "elk" [shape="component"] + "api" [shape="component"] + "piwik" [shape="component"] + "logs" [shape="component"] + "html" [shape="component"] + 2580 [shape="circle"] + 2480 [shape="circle"] + "http" -> "front" [style="solid"] + 2380 -> "front" [style="solid" label=80] + "mysql" -> "ambassador" [style="solid"] + "orientdb" -> "ambassador" [style="solid"] + "elk" -> "ambassador" [style="solid"] + "api" -> "http" [style="solid"] + "piwik" -> "http" [style="solid"] + "logs" -> "http" [style="dashed"] + "piwik" -> "http" [style="dashed"] + "html" -> "http" [style="dashed"] + "ambassador" -> "api" [style="solid" label="graphdb"] + "ambassador" -> "api" [style="solid" label="reldb"] + "logs" -> "api" [style="dashed"] + "ambassador" -> "logs" [style="solid" label="logstash"] + 2580 -> "elk" [style="solid" label=80] + "ambassador" -> "piwik" [style="solid" label="db"] + 2480 -> "orientdb" [style="solid"] +} +``` + +### `image` renderer + +![image renderer](resources/image.png) + +### `display` renderer + +![display renderer](resources/display.png) + +## License + +The MIT License (MIT) +Copyright (c) 2016 PMSIpilot diff --git a/bin/dcv b/bin/dcv new file mode 100644 index 0000000..cb5d7ec --- /dev/null +++ b/bin/dcv @@ -0,0 +1,24 @@ +#!/usr/bin/env php + diff --git a/bin/kahlan b/bin/kahlan new file mode 100644 index 0000000..27357d5 --- /dev/null +++ b/bin/kahlan @@ -0,0 +1,48 @@ +#!/usr/bin/env php +service('suite.global', function() { + return new Suite(); +}); + +$specs = new Kahlan([ + 'autoloader' => reset($autoloaders), + 'suite' => $box->get('suite.global') +]); +$specs->loadConfig($argv); +$specs->run(); +exit($specs->status()); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1cc67e2 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "pmsipilot/docker-compose-viz", + "description": "Docker compose graph visualization", + "require": { + "symfony/yaml": "^3.1", + "symfony/console": "^3.1", + "clue/graph": "^0.9", + "graphp/graphviz": "^0.2" + }, + "require-dev": { + "crysalead/kahlan": "^2.5" + }, + "license": "MIT", + "authors": [ + { + "name": "Julien Bianchi", + "email": "julien.bianchi@pmsipilot.com" + } + ], + "autoload": { + "files": ["src/functions.php"], + "psr-4": { + "PMSIpilot\\DockerComposeViz\\": "src/" + } + }, + "config": { + "bin-dir": "bin/" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..c99c1cf --- /dev/null +++ b/composer.lock @@ -0,0 +1,376 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "This file is @generated automatically" + ], + "hash": "466baff14323f499d4a7257cdd2805bb", + "content-hash": "4685875c2d24d2ba11ca3ff77293a406", + "packages": [ + { + "name": "clue/graph", + "version": "v0.9.0", + "source": { + "type": "git", + "url": "https://github.com/clue/graph.git", + "reference": "0336a4d5229fa61a20ccceaeab25e52ac9542700" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/graph/zipball/0336a4d5229fa61a20ccceaeab25e52ac9542700", + "reference": "0336a4d5229fa61a20ccceaeab25e52ac9542700", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "suggest": { + "graphp/algorithms": "Common graph algorithms, such as Dijkstra and Moore-Bellman-Ford (shortest path), minimum spanning tree (MST), Kruskal, Prim and many more..", + "graphp/graphviz": "GraphViz graph drawing / DOT output" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fhaculty\\Graph\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A mathematical graph/network library written in PHP", + "homepage": "https://github.com/clue/graph", + "keywords": [ + "edge", + "graph", + "mathematical", + "network", + "vertex" + ], + "time": "2015-03-07 18:11:31" + }, + { + "name": "graphp/algorithms", + "version": "v0.8.1", + "source": { + "type": "git", + "url": "https://github.com/graphp/algorithms.git", + "reference": "81db4049c35730767ec8f97fb5c4844234b86cef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/graphp/algorithms/zipball/81db4049c35730767ec8f97fb5c4844234b86cef", + "reference": "81db4049c35730767ec8f97fb5c4844234b86cef", + "shasum": "" + }, + "require": { + "clue/graph": "~0.9.0|~0.8.0", + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Graphp\\Algorithms\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "Common mathematical graph algorithms", + "homepage": "https://github.com/graphp/algorithms", + "keywords": [ + "Graph algorithms", + "dijkstra", + "kruskal", + "minimum spanning tree", + "moore-bellman-ford", + "prim", + "shortest path" + ], + "time": "2015-03-08 10:12:01" + }, + { + "name": "graphp/graphviz", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/graphp/graphviz.git", + "reference": "2676522dfcd907fd3cb52891ea64a052c4ac4c2a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/graphp/graphviz/zipball/2676522dfcd907fd3cb52891ea64a052c4ac4c2a", + "reference": "2676522dfcd907fd3cb52891ea64a052c4ac4c2a", + "shasum": "" + }, + "require": { + "clue/graph": "~0.9.0|~0.8.0", + "graphp/algorithms": "~0.8.0", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Graphp\\GraphViz\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "GraphViz graph drawing for mathematical graph/network", + "homepage": "https://github.com/graphp/graphviz", + "keywords": [ + "dot output", + "graph drawing", + "graph image", + "graphviz" + ], + "time": "2015-03-08 10:30:28" + }, + { + "name": "symfony/console", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f9e638e8149e9e41b570ff092f8007c477ef0ce5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f9e638e8149e9e41b570ff092f8007c477ef0ce5", + "reference": "f9e638e8149e9e41b570ff092f8007c477ef0ce5", + "shasum": "" + }, + "require": { + "php": ">=5.5.9", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/event-dispatcher": "~2.8|~3.0", + "symfony/process": "~2.8|~3.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "time": "2016-07-26 08:04:17" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "dff51f72b0706335131b00a7f49606168c582594" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/dff51f72b0706335131b00a7f49606168c582594", + "reference": "dff51f72b0706335131b00a7f49606168c582594", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2016-05-18 14:26:46" + }, + { + "name": "symfony/yaml", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "1819adf2066880c7967df7180f4f662b6f0567ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1819adf2066880c7967df7180f4f662b6f0567ac", + "reference": "1819adf2066880c7967df7180f4f662b6f0567ac", + "shasum": "" + }, + "require": { + "php": ">=5.5.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "time": "2016-07-17 14:02:08" + } + ], + "packages-dev": [ + { + "name": "crysalead/kahlan", + "version": "2.5.4", + "source": { + "type": "git", + "url": "https://github.com/crysalead/kahlan.git", + "reference": "0f5deb7faa3a7a324bf0dc0186190ea18bc1eff3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/crysalead/kahlan/zipball/0f5deb7faa3a7a324bf0dc0186190ea18bc1eff3", + "reference": "0f5deb7faa3a7a324bf0dc0186190ea18bc1eff3", + "shasum": "" + }, + "require": { + "php": ">=5.4" + }, + "bin": [ + "bin/kahlan" + ], + "type": "library", + "autoload": { + "psr-4": { + "Kahlan\\": "src/" + }, + "files": [ + "src/init.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CrysaLEAD" + } + ], + "description": "Behavior-Driven Development (BDD) library.", + "keywords": [ + "BDD", + "Behavior-Driven Development", + "Monkey Patching", + "TDD", + "mock", + "stub", + "testing", + "unit test" + ], + "time": "2016-06-15 15:07:49" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/resources/dependencies.png b/resources/dependencies.png new file mode 100644 index 0000000..b1d54ae Binary files /dev/null and b/resources/dependencies.png differ diff --git a/resources/display.png b/resources/display.png new file mode 100644 index 0000000..a68483c Binary files /dev/null and b/resources/display.png differ diff --git a/resources/image.png b/resources/image.png new file mode 100644 index 0000000..a98bc87 Binary files /dev/null and b/resources/image.png differ diff --git a/resources/links.png b/resources/links.png new file mode 100644 index 0000000..9431048 Binary files /dev/null and b/resources/links.png differ diff --git a/resources/ports.png b/resources/ports.png new file mode 100644 index 0000000..7a8c95d Binary files /dev/null and b/resources/ports.png differ diff --git a/resources/volumes.png b/resources/volumes.png new file mode 100644 index 0000000..5619d96 Binary files /dev/null and b/resources/volumes.png differ diff --git a/spec/fetch-networks.php b/spec/fetch-networks.php new file mode 100644 index 0000000..6cac43b --- /dev/null +++ b/spec/fetch-networks.php @@ -0,0 +1,23 @@ + ['image' => 'bar']]; + + expect(fetchNetworks($configuration))->toBe([]); + }); + }); + + describe('from a version 2 configuration', function() { + it('should fetch networks from the dedicated section', function() { + $configuration = ['version' => 2, 'networks' => ['foo' => [], 'bar' => []]]; + + expect(fetchNetworks($configuration))->toBe($configuration['networks']); + }); + }); +}); diff --git a/spec/fetch-services.php b/spec/fetch-services.php new file mode 100644 index 0000000..0741acd --- /dev/null +++ b/spec/fetch-services.php @@ -0,0 +1,23 @@ + ['image' => 'bar'], 'baz' => ['build' => '.']]; + + expect(fetchServices($configuration))->toBe($configuration); + }); + }); + + describe('from a version 2 configuration', function() { + it('should fetch services from the dedicated section', function() { + $configuration = ['version' => 2, 'services' => ['foo' => ['image' => 'bar'], 'baz' => ['build' => '.']]]; + + expect(fetchServices($configuration))->toBe($configuration['services']); + }); + }); +}); diff --git a/spec/fetch-volumes.php b/spec/fetch-volumes.php new file mode 100644 index 0000000..17a50ed --- /dev/null +++ b/spec/fetch-volumes.php @@ -0,0 +1,23 @@ + ['image' => 'bar']]; + + expect(fetchVolumes($configuration))->toBe([]); + }); + }); + + describe('from a version 2 configuration', function() { + it('should fetch volumes from the dedicated section', function() { + $configuration = ['version' => 2, 'volumes' => ['foo' => [], 'bar' => []]]; + + expect(fetchVolumes($configuration))->toBe($configuration['volumes']); + }); + }); +}); diff --git a/spec/fixtures/read-configuration/invalid.json b/spec/fixtures/read-configuration/invalid.json new file mode 100644 index 0000000..f3a9538 --- /dev/null +++ b/spec/fixtures/read-configuration/invalid.json @@ -0,0 +1,8 @@ +{ + "version": 2, + "services": { + "foo": { + "image": "bar" + } + } +} diff --git a/spec/fixtures/read-configuration/valid.yml b/spec/fixtures/read-configuration/valid.yml new file mode 100644 index 0000000..d7341a6 --- /dev/null +++ b/spec/fixtures/read-configuration/valid.yml @@ -0,0 +1,4 @@ +version: 2 +services: + foo: + image: bar diff --git a/spec/read-configuratoin.php b/spec/read-configuratoin.php new file mode 100644 index 0000000..067476b --- /dev/null +++ b/spec/read-configuratoin.php @@ -0,0 +1,22 @@ +toThrow(new InvalidArgumentException()); + }); + + it('should parse YAML and return an array', function() { + expect(readConfiguration(__DIR__.'/fixtures/read-configuration/valid.yml')) + ->toBe(['version' => 2, 'services' => ['foo' => ['image' => 'bar']]]); + }); + + it('should report if YAML is invalid', function() { + expect(function() { readConfiguration(__DIR__.'/fixtures/read-configuration/invalid.json'); }) + ->toThrow(new InvalidArgumentException()); + }); +}); diff --git a/src/application.php b/src/application.php new file mode 100644 index 0000000..4fc396e --- /dev/null +++ b/src/application.php @@ -0,0 +1,94 @@ +register('render') + ->addArgument('input-file',Console\Input\InputArgument::OPTIONAL, 'Path to a docker compose file', getcwd().DIRECTORY_SEPARATOR.'docker-compose.yml') + + ->addOption('output-file', 'o', Console\Input\InputOption::VALUE_REQUIRED, 'Path to a output file (Only for "dot" and "image" output format)') + ->addOption('output-format', 'm', Console\Input\InputOption::VALUE_REQUIRED, 'Output format (one of: "dot", "image", "display")', 'display') + ->addOption('only', null, Console\Input\InputOption::VALUE_IS_ARRAY | Console\Input\InputOption::VALUE_REQUIRED, 'Display a graph only for a given services') + + ->addOption('force', 'f', Console\Input\InputOption::VALUE_NONE, 'Overwrites output file if it already exists') + ->addOption('no-volumes', null, Console\Input\InputOption::VALUE_NONE, 'Do not display volumes') + ->addOption('horizontal', 'r', Console\Input\InputOption::VALUE_NONE, 'Display a horizontal graph') + + ->setCode(function(Console\Input\InputInterface $input, Console\Output\OutputInterface $output) { + $inputFile = $input->getArgument('input-file'); + $outputFormat = $input->getOption('output-format'); + $outputFile = $input->getOption('output-file') ?: getcwd().DIRECTORY_SEPARATOR.'docker-compose.'.($outputFormat === 'dot' ? $outputFormat : 'png'); + $onlyServices = $input->getOption('only'); + + if (in_array($outputFormat, ['dot', 'image', 'display']) === false) { + throw new Console\Exception\InvalidArgumentException(sprintf('Invalid output format "%s". It must be one of "dot", "png" or "display".', $outputFormat)); + } + + if ($outputFormat === 'display') { + if ($input->getOption('force') || $input->getOption('output-file')) { + $output->writeln('The following options are ignored with the "display" output format: "--force", "--output-file"'); + } + } else { + if (file_exists($outputFile) === true && $input->getOption('force') === false) { + throw new Console\Exception\InvalidArgumentException(sprintf('File "%s" already exists. Use the "--force" option to overwrite it.', $outputFile)); + } + } + + $configuration = readConfiguration($inputFile); + $services = fetchServices($configuration); + $volumes = fetchVolumes($configuration); + $networks = fetchNetworks($configuration); + + if ([] !== $onlyServices) { + $intersect = array_intersect($onlyServices, array_keys($services)); + + if ($intersect !== $onlyServices) { + throw new Console\Exception\InvalidArgumentException(sprintf('The following services do not exist: "%s"', implode('", "', array_diff($onlyServices, $intersect)))); + } + + $services = array_filter( + $services, + function($service) use ($onlyServices) { + return in_array($service, $onlyServices); + }, + ARRAY_FILTER_USE_KEY + ); + } + + $graph = applyGraphvizStyle( + createGraph($services, $volumes, $networks, $input->getOption('no-volumes') === false), + $input->getOption('horizontal') + ); + + switch ($outputFormat) { + case 'dot': + case 'image': + $rendererClass = 'Graphp\GraphViz\\' . ucfirst($outputFormat); + $renderer = new $rendererClass(); + + file_put_contents($outputFile, $renderer->getOutput($graph)); + break; + + case 'display': + $renderer = new GraphViz(); + $renderer->display($graph); + break; + } + + }); + +$application->run(); diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..3d96e60 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,424 @@ +createGraphClone(); + $graph->setAttribute('graphviz.graph.pad', '0.5'); + + if ($horizontal === true) { + $graph->setAttribute('graphviz.graph.rankdir', 'LR'); + } + + foreach ($graph->getVertices() as $vertex) { + switch ($vertex->getAttribute('docker_compose.type')) { + case 'service': + $vertex->setAttribute('graphviz.shape', 'component'); + break; + + case 'external_service': + $vertex->setAttribute('graphviz.shape', 'component'); + $vertex->setAttribute('graphviz.color', 'gray'); + break; + + case 'volume': + $vertex->setAttribute('graphviz.shape', 'folder'); + break; + + case 'network': + $vertex->setAttribute('graphviz.shape', 'pentagon'); + break; + + case 'external_network': + $vertex->setAttribute('graphviz.shape', 'pentagon'); + $vertex->setAttribute('graphviz.color', 'gray'); + break; + + case 'port': + $vertex->setAttribute('graphviz.shape', 'circle'); + + if (($proto = $vertex->getAttribute('docker_compose.proto')) === 'udp') { + $vertex->setAttribute('graphviz.style', 'dashed'); + } + break; + } + } + + foreach ($graph->getEdges() as $edge) { + switch ($edge->getAttribute('docker_compose.type')) { + case 'ports': + case 'links': + $edge->setAttribute('graphviz.style', 'solid'); + break; + + case 'external_links': + $edge->setAttribute('graphviz.style', 'solid'); + $edge->setAttribute('graphviz.color', 'gray'); + break; + + case 'volumes_from': + case 'volumes': + $edge->setAttribute('graphviz.style', 'dashed'); + break; + + case 'depends_on': + $edge->setAttribute('graphviz.style', 'dotted'); + break; + } + + if (($alias = $edge->getAttribute('docker_compose.alias')) !== null) { + $edge->setAttribute('graphviz.label', $alias); + } + + if ($edge->getAttribute('docker_compose.bidir')) { + $edge->setAttribute('graphviz.dir', 'both'); + } + } + + return $graph; +} + +/** + * @internal + * + * @param Graph $graph Input graph + * @param array $services Docker compose service definitions + * @param array $volumes Docker compose volume definitions + * @param array $networks Docker compose network definitions + * @param bool $withVolumes Create vertices and edges for volumes + * + * @return Graph A copy of the input graph with vertices and edges for services + */ +function makeVerticesAndEdges(Graph $graph, array $services, array $volumes, array $networks, bool $withVolumes) : Graph +{ + $graph = $graph->createGraphClone(); + + if ($withVolumes === true) { + foreach (array_keys($volumes) as $volume) { + addVolume($graph, 'named: '.$volume); + } + } + + foreach ($networks as $network => $definition) { + addNetwork( + $graph, 'net: '.$network, + isset($definition['external']) && $definition['external'] === true ? 'external_network' : 'network' + ); + } + + foreach ($services as $service => $definition) { + $vertices[$service] = addService($graph, $service); + + foreach ($definition['links'] ?? [] as $link) { + list($target, $alias) = explodeMapping($link); + + addRelation( + addService($graph, $target), + $graph->getVertex($service), + 'links', + $alias !== $target ? $alias : null + ); + } + + foreach ($definition['external_links'] ?? [] as $link) { + list($target, $alias) = explodeMapping($link); + + addRelation( + addService($graph, $target, 'external_service'), + $graph->getVertex($service), + 'external_links', + $alias !== $target ? $alias : null + ); + } + + foreach ($definition['depends_on'] ?? [] as $dependency) { + addRelation( + $graph->getVertex($service), + addService($graph, $dependency), + 'depends_on' + ); + } + + foreach ($definition['volumes_from'] ?? [] as $source) { + addRelation( + addService($graph, $source), + $graph->getVertex($service), + 'volumes_from' + ); + } + + if ($withVolumes === true) { + foreach ($definition['volumes'] ?? [] as $volume) { + list($host, $container, $attr) = explodeMapping($volume); + + if ($host[0] !== '.' && $host[0] !== DIRECTORY_SEPARATOR) { + $host = 'named: '.$host; + } + + addRelation( + addVolume($graph, $host), + $graph->getVertex($service), + 'volumes', + $host !== $container ? $container : null, + $attr !== 'ro' + ); + } + } + + foreach ($definition['ports'] ?? [] as $port) { + list($host, $container, $proto) = explodeMapping($port); + + addRelation( + addPort($graph, (int) $host, $proto), + $graph->getVertex($service), + 'ports', + $host !== $container ? $container : null + ); + } + + foreach ($definition['networks'] ?? [] as $network => $config) { + $network = is_int($network) ? $config : $network; + $config = is_int($network) ? [] : $config; + $aliases = $config['aliases'] ?? []; + + addRelation( + $graph->getVertex($service), + addNetwork($graph, 'net: '.$network), + 'networks', + count($aliases) > 0 ? implode(', ', $aliases) : null + ); + } + } + + return $graph; +} + +/** + * @internal + * + * @param Graph $graph Input graph + * @param string $service Service name + * @param string $type Service type + * + * @return Vertex + */ +function addService(Graph $graph, string $service, string $type = null) +{ + if ($graph->hasVertex($service) === true) { + return $graph->getVertex($service); + } + + $vertex = $graph->createVertex($service); + $vertex->setAttribute('docker_compose.type', $type ?: 'service'); + + return $vertex; +} + +/** + * @internal + * + * @param Graph $graph Input graph + * @param int $port Port number + * @param string|null $proto Protocol + * + * @return Vertex + */ +function addPort(Graph $graph, int $port, string $proto = null) +{ + if ($graph->hasVertex($port) === true) { + return $graph->getVertex($port); + } + + $vertex = $graph->createVertex($port); + $vertex->setAttribute('docker_compose.type', 'port'); + $vertex->setAttribute('docker_compose.proto', $proto ?: 'tcp'); + + return $vertex; +} + +/** + * @internal + * + * @param Graph $graph Input graph + * @param string $path Path + * + * @return Vertex + */ +function addVolume(Graph $graph, string $path) +{ + if ($graph->hasVertex($path) === true) { + return $graph->getVertex($path); + } + + $vertex = $graph->createVertex($path); + $vertex->setAttribute('docker_compose.type', 'volume'); + + return $vertex; +} + +/** + * @internal + * + * @param Graph $graph Input graph + * @param string $name Name of the network + * @param string $type Network type + * + * @return Vertex + */ +function addNetwork(Graph $graph, string $name, string $type = null) +{ + if ($graph->hasVertex($name) === true) { + return $graph->getVertex($name); + } + + $vertex = $graph->createVertex($name); + $vertex->setAttribute('docker_compose.type', $type ?: 'network'); + + return $vertex; +} + +/** + * @internal + * + * @param Vertex $from Source vertex + * @param Vertex $to Destination vertex + * @param string $type Type of the relation (one of "links", "volumes_from", "depends_on", "ports"); + * @param string|null $alias Alias associated to the linked element + * @param bool|null $bidirectional Biderectional or not + * + * @return Edge\Directed + */ +function addRelation(Vertex $from, Vertex $to, string $type, string $alias = null, bool $bidirectional = false) : Edge\Directed +{ + $edge = $from->createEdgeTo($to); + $edge->setAttribute('docker_compose.type', $type); + + if ($alias !== null) { + $edge->setAttribute('docker_compose.alias', $alias); + } + + $edge->setAttribute('docker_compose.bidir', $bidirectional); + + return $edge; +} + +/** + * @internal + * + * @param string $mapping A docker mapping ([:]) + * + * @return array An 2 items array containing the parts of the mapping. + * If the mapping does not specify a second part, the first one will be repeated + */ +function explodeMapping($mapping) : array +{ + $parts = explode(':', $mapping); + $parts[1] = $parts[1] ?? $parts[0]; + + $subparts = array_values(array_filter(explode('/', $parts[1]))); + + if (count($subparts) > 2) { + $subparts = [$parts[1], $parts[2] ?? null]; + } + + return [$parts[0], $subparts[0], $subparts[1] ?? null]; +}