diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1577053..9d6b488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,18 +20,6 @@ jobs: sudo apt-get update sudo apt-get install -y graphviz - - name: Validate Test Files - run: | - docker compose -f tests/ymls/builds/docker-compose.yml config -q - docker compose -f tests/ymls/depends_on/docker-compose.yml config -q - docker compose -f tests/ymls/extends/docker-compose.yml config -q - docker compose -f tests/ymls/links/docker-compose.yml config -q - docker compose -f tests/ymls/networks/docker-compose.yml config -q - docker compose -f tests/ymls/ports/docker-compose.yml config -q - docker compose -f tests/ymls/volumes/docker-compose.yml config -q - docker compose -f examples/full-stack-node-app/docker-compose.yml config -q - docker compose -f examples/non-normative/docker-compose.yml config -q - - name: Setup Python 3.10.4 uses: actions/setup-python@v3 with: diff --git a/compose_viz/graph.py b/compose_viz/graph.py index 3f634f2..0d9a8a9 100644 --- a/compose_viz/graph.py +++ b/compose_viz/graph.py @@ -5,13 +5,13 @@ import graphviz from compose_viz.models.compose import Compose -def apply_vertex_style(type) -> dict: +def apply_vertex_style(type: str) -> dict: style = { "service": { "shape": "component", }, "volume": { - "shape": "folder", + "shape": "cylinder", }, "network": { "shape": "pentagon", @@ -19,24 +19,39 @@ def apply_vertex_style(type) -> dict: "port": { "shape": "circle", }, + "env_file": { + "shape": "tab", + }, + "porfile": { + "shape": "invhouse", + }, + "cgroup": { + "shape": "diamond", + }, + "device": { + "shape": "box3d", + }, } return style[type] -def apply_edge_style(type) -> dict: +def apply_edge_style(type: str) -> dict: style = { - "ports": { + "exposes": { "style": "solid", "dir": "both", }, "links": { "style": "solid", }, - "volumes": { + "volumes_rw": { "style": "dashed", "dir": "both", }, + "volumes_ro": { + "style": "dashed", + }, "depends_on": { "style": "dotted", }, @@ -45,6 +60,9 @@ def apply_edge_style(type) -> dict: "arrowhead": "inv", "arrowtail": "dot", }, + "env_file": { + "style": "solid", + }, } return style[type] @@ -71,19 +89,38 @@ class Graph: def render(self, format: str, cleanup: bool = True) -> None: for service in self.compose.services: if service.image is not None: - self.add_vertex(service.name, "service", lable=f"{service.name}\n({service.image})") + self.add_vertex( + service.name, + "service", + lable=f"{service.container_name if service.container_name else service.name}\n({service.image})", + ) if service.extends is not None: self.add_vertex(service.name, "service", lable=f"{service.name}\n") self.add_edge(service.extends.service_name, service.name, "extends") + if service.cgroup_parent is not None: + self.add_vertex(service.cgroup_parent, "cgroup") + self.add_edge(service.name, service.cgroup_parent, "links") + for network in service.networks: self.add_vertex(network, "network", lable=f"net:{network}") self.add_edge(service.name, network, "links") for volume in service.volumes: self.add_vertex(volume.source, "volume") - self.add_edge(service.name, volume.source, "volumes", lable=volume.target) + self.add_edge( + service.name, + volume.source, + "volumes_rw" if "rw" in volume.access_mode else "volumes_ro", + lable=volume.target, + ) + for expose in service.expose: + self.add_vertex(expose, "port") + self.add_edge(expose, service.name, "exposes") for port in service.ports: self.add_vertex(port.host_port, "port", lable=port.host_port) - self.add_edge(port.host_port, service.name, "ports", lable=port.container_port) + self.add_edge(port.host_port, service.name, "links", lable=port.container_port) + for env_file in service.env_file: + self.add_vertex(env_file, "env_file") + self.add_edge(env_file, service.name, "env_file") for link in service.links: if ":" in link: service_name, alias = link.split(":", 1) @@ -92,5 +129,13 @@ class Graph: self.add_edge(link, service.name, "links") for depends_on in service.depends_on: self.add_edge(service.name, depends_on, "depends_on") + for porfile in service.profiles: + self.add_vertex(porfile, "porfile") + self.add_edge(service.name, porfile, "links") + for device in service.devices: + self.add_vertex(device.host_path, "device") + self.add_edge( + device.host_path, service.name, "exposes", f"{device.container_path}\n({device.cgroup_permissions})" + ) self.dot.render(outfile=f"{self.filename}.{format}", format=format, cleanup=cleanup) diff --git a/compose_viz/models/device.py b/compose_viz/models/device.py new file mode 100644 index 0000000..80d6b1e --- /dev/null +++ b/compose_viz/models/device.py @@ -0,0 +1,20 @@ +from typing import Optional + + +class Device: + def __init__(self, host_path: str, container_path: str, cgroup_permissions: Optional[str] = None): + self._host_path = host_path + self._container_path = container_path + self._cgroup_permissions = cgroup_permissions + + @property + def host_path(self): + return self._host_path + + @property + def container_path(self): + return self._container_path + + @property + def cgroup_permissions(self): + return self._cgroup_permissions diff --git a/compose_viz/models/service.py b/compose_viz/models/service.py index 89740ba..8e578c2 100644 --- a/compose_viz/models/service.py +++ b/compose_viz/models/service.py @@ -1,5 +1,6 @@ from typing import List, Optional +from compose_viz.models.device import Device from compose_viz.models.extends import Extends from compose_viz.models.port import Port from compose_viz.models.volume import Volume @@ -16,6 +17,12 @@ class Service: depends_on: List[str] = [], links: List[str] = [], extends: Optional[Extends] = None, + cgroup_parent: Optional[str] = None, + container_name: Optional[str] = None, + devices: List[Device] = [], + env_file: List[str] = [], + expose: List[str] = [], + profiles: List[str] = [], ) -> None: self._name = name self._image = image @@ -25,6 +32,12 @@ class Service: self._depends_on = depends_on self._links = links self._extends = extends + self._cgroup_parent = cgroup_parent + self._container_name = container_name + self._devices = devices + self._env_file = env_file + self._expose = expose + self._profiles = profiles @property def name(self): @@ -57,3 +70,27 @@ class Service: @property def extends(self): return self._extends + + @property + def cgroup_parent(self): + return self._cgroup_parent + + @property + def container_name(self): + return self._container_name + + @property + def devices(self): + return self._devices + + @property + def env_file(self): + return self._env_file + + @property + def expose(self): + return self._expose + + @property + def profiles(self): + return self._profiles diff --git a/compose_viz/parser.py b/compose_viz/parser.py index 63139d4..c67186c 100644 --- a/compose_viz/parser.py +++ b/compose_viz/parser.py @@ -5,6 +5,7 @@ from pydantic import ValidationError import compose_viz.spec.compose_spec as spec from compose_viz.models.compose import Compose, Service +from compose_viz.models.device import Device from compose_viz.models.extends import Extends from compose_viz.models.port import Port, Protocol from compose_viz.models.volume import Volume, VolumeType @@ -176,6 +177,50 @@ class Parser: if service_data.links is not None: service_links = service_data.links + cgroup_parent: Optional[str] = None + if service_data.cgroup_parent is not None: + cgroup_parent = service_data.cgroup_parent + + container_name: Optional[str] = None + if service_data.container_name is not None: + container_name = service_data.container_name + + env_file: List[str] = [] + if service_data.env_file is not None: + if type(service_data.env_file) is spec.StringOrList: + if type(service_data.env_file.__root__) is spec.ListOfStrings: + env_file = service_data.env_file.__root__.__root__ + elif type(service_data.env_file.__root__) is str: + env_file.append(service_data.env_file.__root__) + + expose: List[str] = [] + if service_data.expose is not None: + for port in service_data.expose: + expose.append(str(port)) + + profiles: List[str] = [] + if service_data.profiles is not None: + if type(service_data.profiles) is spec.ListOfStrings: + profiles = service_data.profiles.__root__ + + devices: List[Device] = [] + if service_data.devices is not None: + for device_data in service_data.devices: + if type(device_data) is str: + assert ":" in device_data, "Invalid volume input, aborting." + + spilt_data = device_data.split(":") + if len(spilt_data) == 2: + devices.append(Device(host_path=spilt_data[0], container_path=spilt_data[1])) + elif len(spilt_data) == 3: + devices.append( + Device( + host_path=spilt_data[0], + container_path=spilt_data[1], + cgroup_permissions=spilt_data[2], + ) + ) + services.append( Service( name=service_name, @@ -186,6 +231,12 @@ class Parser: depends_on=service_depends_on, volumes=service_volumes, links=service_links, + cgroup_parent=cgroup_parent, + container_name=container_name, + env_file=env_file, + expose=expose, + profiles=profiles, + devices=devices, ) ) diff --git a/examples/full-stack-node-app/compose-viz.png b/examples/full-stack-node-app/compose-viz.png index 2ccd6ce..7c0f902 100644 Binary files a/examples/full-stack-node-app/compose-viz.png and b/examples/full-stack-node-app/compose-viz.png differ diff --git a/examples/non-normative/compose-viz.png b/examples/non-normative/compose-viz.png index 805094a..f4fdb1e 100644 Binary files a/examples/non-normative/compose-viz.png and b/examples/non-normative/compose-viz.png differ diff --git a/examples/non-normative/docker-compose.yml b/examples/non-normative/docker-compose.yml index 2ed41b9..8419c98 100644 --- a/examples/non-normative/docker-compose.yml +++ b/examples/non-normative/docker-compose.yml @@ -6,10 +6,19 @@ services: - back-tier monitoring: + env_file: + - a.env + - b.env + container_name: monitoring-server image: awesome/monitoring networks: - admin - + expose: + - 1234 + profiles: + - tools + - foo + cgroup_parent: awesome-parent backend: networks: @@ -36,8 +45,12 @@ services: - "8000:5010" links: - "db:database" + cgroup_parent: awesome-parent db: image: postgres + devices: + - "/dev/ttyUSB2:/dev/ttyUSB3" + - "/dev/sda:/dev/xvda:rwm" networks: front-tier: diff --git a/examples/voting-app/compose-viz.png b/examples/voting-app/compose-viz.png index 64486c2..6a9fb8e 100644 Binary files a/examples/voting-app/compose-viz.png and b/examples/voting-app/compose-viz.png differ diff --git a/examples/voting-app/compose-viz.svg b/examples/voting-app/compose-viz.svg index f16a8ee..fb12a7a 100644 --- a/examples/voting-app/compose-viz.svg +++ b/examples/voting-app/compose-viz.svg @@ -38,8 +38,7 @@ 0.0.0.06379->redis - - + 6379 @@ -67,7 +66,8 @@ db-data - + + db-data @@ -108,8 +108,7 @@ 0.0.0.05000->vote - - + 80 @@ -143,8 +142,7 @@ 0.0.0.05001->result - - + 80 @@ -181,7 +179,8 @@ /var/run/docker.sock - + + /var/run/docker.sock @@ -201,8 +200,7 @@ 0.0.0.08080->visualizer - - + 8080 diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e2f37e..503047b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,11 +12,17 @@ runner = CliRunner() "test_file_path", [ "tests/ymls/builds/docker-compose.yml", + "tests/ymls/cgroup_parent/docker-compose.yml", + "tests/ymls/container_name/docker-compose.yml", "tests/ymls/depends_on/docker-compose.yml", + "tests/ymls/devices/docker-compose.yml", + "tests/ymls/env_file/docker-compose.yml", + "tests/ymls/expose/docker-compose.yml", "tests/ymls/extends/docker-compose.yml", "tests/ymls/links/docker-compose.yml", "tests/ymls/networks/docker-compose.yml", "tests/ymls/ports/docker-compose.yml", + "tests/ymls/profiles/docker-compose.yml", "tests/ymls/volumes/docker-compose.yml", "examples/full-stack-node-app/docker-compose.yml", "examples/non-normative/docker-compose.yml", diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..cbf0042 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,22 @@ +from compose_viz.models.device import Device + + +def test_device_init_normal() -> None: + try: + d = Device(host_path="/dev/ttyUSB0", container_path="/dev/ttyUSB1") + + assert d.host_path == "/dev/ttyUSB0" + assert d.container_path == "/dev/ttyUSB1" + except Exception as e: + assert False, e + + +def test_device_with_cgroup_permissions() -> None: + try: + d = Device(host_path="/dev/sda1", container_path="/dev/xvda", cgroup_permissions="rwm") + + assert d.host_path == "/dev/sda1" + assert d.container_path == "/dev/xvda" + assert d.cgroup_permissions == "rwm" + except Exception as e: + assert False, e diff --git a/tests/test_parse_file.py b/tests/test_parse_file.py index 7bf3cdc..095c024 100644 --- a/tests/test_parse_file.py +++ b/tests/test_parse_file.py @@ -1,6 +1,7 @@ import pytest from compose_viz.models.compose import Compose +from compose_viz.models.device import Device from compose_viz.models.extends import Extends from compose_viz.models.port import Port, Protocol from compose_viz.models.service import Service @@ -241,6 +242,123 @@ from compose_viz.parser import Parser ], ), ), + ( + "cgroup_parent/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + cgroup_parent="system", + ), + ], + ), + ), + ( + "container_name/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + container_name="myfrontend", + ), + ], + ), + ), + ( + "env_file/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + env_file=["a.env"], + ), + Service( + name="backend", + image="awesome/backend", + env_file=["b.env"], + ), + Service( + name="db", + image="awesome/db", + env_file=["c.env", "d.env"], + ), + ], + ), + ), + ( + "expose/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + expose=["27118"], + ), + Service( + name="backend", + image="awesome/backend", + expose=["27017", "27018"], + ), + ], + ), + ), + ( + "profiles/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + profiles=["frontend"], + ), + Service( + name="phpmyadmin", + image="phpmyadmin", + profiles=["debug"], + ), + Service( + name="db", + image="awesome/db", + profiles=["db", "sql"], + ), + ], + ), + ), + ( + "devices/docker-compose", + Compose( + services=[ + Service( + name="frontend", + image="awesome/frontend", + devices=[ + Device( + host_path="/dev/ttyUSB0", + container_path="/dev/ttyUSB1", + ) + ], + ), + Service( + name="backend", + image="awesome/backend", + devices=[ + Device( + host_path="/dev/ttyUSB2", + container_path="/dev/ttyUSB3", + ), + Device( + host_path="/dev/sda", + container_path="/dev/xvda", + cgroup_permissions="rwm", + ), + ], + ), + ], + ), + ), ], ) def test_parse_file(test_file_path: str, expected: Compose) -> None: @@ -275,3 +393,16 @@ def test_parse_file(test_file_path: str, expected: Compose) -> None: if (actual_service.extends is not None) and (expected_service.extends is not None): assert actual_service.extends.service_name == expected_service.extends.service_name assert actual_service.extends.from_file == expected_service.extends.from_file + + assert actual_service.cgroup_parent == expected_service.cgroup_parent + assert actual_service.container_name == expected_service.container_name + + assert actual_service.expose == expected_service.expose + assert actual_service.env_file == expected_service.env_file + assert actual_service.profiles == expected_service.profiles + + assert len(actual_service.devices) == len(expected_service.devices) + for actual_device, expected_device in zip(actual_service.devices, expected_service.devices): + assert actual_device.host_path == expected_device.host_path + assert actual_device.container_path == expected_device.container_path + assert actual_device.cgroup_permissions == expected_device.cgroup_permissions diff --git a/tests/ymls/cgroup_parent/docker-compose.yml b/tests/ymls/cgroup_parent/docker-compose.yml new file mode 100644 index 0000000..6d41ad4 --- /dev/null +++ b/tests/ymls/cgroup_parent/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3.9" + +services: + frontend: + image: awesome/frontend + cgroup_parent: "system" diff --git a/tests/ymls/container_name/docker-compose.yml b/tests/ymls/container_name/docker-compose.yml new file mode 100644 index 0000000..08047c2 --- /dev/null +++ b/tests/ymls/container_name/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3.9" + +services: + frontend: + image: awesome/frontend + container_name: "myfrontend" diff --git a/tests/ymls/devices/docker-compose.yml b/tests/ymls/devices/docker-compose.yml new file mode 100644 index 0000000..2f810a3 --- /dev/null +++ b/tests/ymls/devices/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.9" + +services: + frontend: + image: awesome/frontend + devices: + - "/dev/ttyUSB0:/dev/ttyUSB1" + backend: + image: awesome/backend + devices: + - "/dev/ttyUSB2:/dev/ttyUSB3" + - "/dev/sda:/dev/xvda:rwm" diff --git a/tests/ymls/env_file/docker-compose.yml b/tests/ymls/env_file/docker-compose.yml new file mode 100644 index 0000000..d687153 --- /dev/null +++ b/tests/ymls/env_file/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.9" + +services: + frontend: + image: awesome/frontend + env_file: a.env + backend: + image: awesome/backend + env_file: + - b.env + db: + image: awesome/db + env_file: + - c.env + - d.env diff --git a/tests/ymls/expose/docker-compose.yml b/tests/ymls/expose/docker-compose.yml new file mode 100644 index 0000000..1e1bdee --- /dev/null +++ b/tests/ymls/expose/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.9" + +services: + frontend: + image: awesome/frontend + expose: + - "27118" + backend: + image: awesome/backend + expose: + - "27017" + - "27018" diff --git a/tests/ymls/profiles/docker-compose.yml b/tests/ymls/profiles/docker-compose.yml new file mode 100644 index 0000000..39282af --- /dev/null +++ b/tests/ymls/profiles/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" +services: + frontend: + image: awesome/frontend + profiles: ["frontend"] + phpmyadmin: + image: phpmyadmin + profiles: + - debug + db: + image: awesome/db + profiles: + - db + - sql