├── tests ├── __init__.py ├── ymls │ ├── others │ │ ├── empty.yml │ │ ├── no-services.yml │ │ └── invalid.yml │ ├── extends │ │ ├── web.yml │ │ └── docker-compose.yml │ ├── cgroup_parent │ │ └── docker-compose.yml │ ├── container_name │ │ └── docker-compose.yml │ ├── links │ │ └── docker-compose.yml │ ├── expose │ │ └── docker-compose.yml │ ├── builds │ │ └── docker-compose.yml │ ├── devices │ │ └── docker-compose.yml │ ├── profiles │ │ └── docker-compose.yml │ ├── env_file │ │ └── docker-compose.yml │ ├── depends_on │ │ └── docker-compose.yml │ ├── ports │ │ └── docker-compose.yml │ ├── networks │ │ └── docker-compose.yml │ └── volumes │ │ └── docker-compose.yml ├── test_module.py ├── test_version.py ├── test_parser.py ├── test_port.py ├── test_devices.py ├── test_extends.py ├── test_volume.py ├── test_root_service.py ├── test_cli.py └── test_parse_file.py ├── compose_viz ├── spec │ ├── __init__.py │ └── compose_spec.py ├── models │ ├── __init__.py │ ├── compose.py │ ├── extends.py │ ├── device.py │ ├── volume.py │ ├── port.py │ ├── viz_formats.py │ └── service.py ├── __init__.py ├── __main__.py ├── cli.py ├── graph.py └── parser.py ├── .gitmodules ├── examples ├── voting-app │ ├── compose-viz.png │ ├── docker-compose.yml │ └── compose-viz.svg ├── non-normative │ ├── compose-viz.png │ └── docker-compose.yml └── full-stack-node-app │ ├── compose-viz.png │ ├── common-services.yml │ └── docker-compose.yml ├── .github └── workflows │ ├── cd.yml │ ├── ci.yml │ ├── release-docker-image.yml │ ├── release-tagged-version.yml │ └── update-submodules.yml ├── Dockerfile ├── pyproject.toml ├── .pre-commit-config.yaml ├── LICENSE ├── update-submodules.py ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compose_viz/spec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ymls/others/empty.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compose_viz/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compose_viz/__init__.py: -------------------------------------------------------------------------------- 1 | __app_name__ = "compose_viz" 2 | __version__ = "0.3.5" 3 | -------------------------------------------------------------------------------- /tests/ymls/others/no-services.yml: -------------------------------------------------------------------------------- 1 | version: "A docker-compose file without services." 2 | -------------------------------------------------------------------------------- /tests/ymls/others/invalid.yml: -------------------------------------------------------------------------------- 1 | what-is-this: 2 | "an invalid yaml" 3 | "test purpose" 4 | -------------------------------------------------------------------------------- /tests/ymls/extends/web.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | web: 5 | image: awesome/web 6 | -------------------------------------------------------------------------------- /compose_viz/__main__.py: -------------------------------------------------------------------------------- 1 | from compose_viz.cli import start_cli 2 | 3 | if __name__ == "__main__": 4 | start_cli() 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "compose-spec"] 2 | path = compose-spec 3 | url = https://github.com/compose-spec/compose-spec.git 4 | -------------------------------------------------------------------------------- /examples/voting-app/compose-viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compose-viz/compose-viz/HEAD/examples/voting-app/compose-viz.png -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def test_module(): 5 | assert os.system("python -m compose_viz --help") == 0 6 | -------------------------------------------------------------------------------- /examples/non-normative/compose-viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compose-viz/compose-viz/HEAD/examples/non-normative/compose-viz.png -------------------------------------------------------------------------------- /examples/full-stack-node-app/compose-viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compose-viz/compose-viz/HEAD/examples/full-stack-node-app/compose-viz.png -------------------------------------------------------------------------------- /tests/ymls/cgroup_parent/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | cgroup_parent: "system" 7 | -------------------------------------------------------------------------------- /tests/ymls/container_name/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | container_name: "myfrontend" 7 | -------------------------------------------------------------------------------- /examples/full-stack-node-app/common-services.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | postgres: 5 | image: awesome/postgres 6 | node: 7 | image: awesome/node 8 | -------------------------------------------------------------------------------- /tests/ymls/links/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | links: 7 | - "db:database" 8 | db: 9 | image: mysql 10 | -------------------------------------------------------------------------------- /tests/ymls/expose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | expose: 7 | - "27118" 8 | backend: 9 | image: awesome/backend 10 | expose: 11 | - "27017" 12 | - "27018" 13 | -------------------------------------------------------------------------------- /tests/ymls/builds/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | build: ./frontend 7 | 8 | backend: 9 | build: 10 | context: backend 11 | dockerfile: ../backend.Dockerfile 12 | 13 | db: 14 | build: 15 | context: ./db 16 | -------------------------------------------------------------------------------- /tests/ymls/devices/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | devices: 7 | - "/dev/ttyUSB0:/dev/ttyUSB1" 8 | backend: 9 | image: awesome/backend 10 | devices: 11 | - "/dev/ttyUSB2:/dev/ttyUSB3" 12 | - "/dev/sda:/dev/xvda:rwm" 13 | -------------------------------------------------------------------------------- /tests/ymls/extends/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | base: 5 | image: alpine:latest 6 | tty: true 7 | derive_from_base: 8 | image: alpine:edge 9 | extends: 10 | service: base 11 | derive_from_file: 12 | extends: 13 | file: web.yml 14 | service: web 15 | -------------------------------------------------------------------------------- /tests/ymls/profiles/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | frontend: 4 | image: awesome/frontend 5 | profiles: ["frontend"] 6 | phpmyadmin: 7 | image: phpmyadmin 8 | profiles: 9 | - debug 10 | db: 11 | image: awesome/db 12 | profiles: 13 | - db 14 | - sql 15 | -------------------------------------------------------------------------------- /tests/ymls/env_file/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | env_file: a.env 7 | backend: 8 | image: awesome/backend 9 | env_file: 10 | - b.env 11 | db: 12 | image: awesome/db 13 | env_file: 14 | - c.env 15 | - d.env 16 | -------------------------------------------------------------------------------- /compose_viz/models/compose.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from compose_viz.models.service import Service 4 | 5 | 6 | class Compose: 7 | def __init__(self, services: List[Service]) -> None: 8 | self._services = services 9 | 10 | @property 11 | def services(self): 12 | return self._services 13 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from compose_viz import __app_name__, __version__, cli 4 | 5 | runner = CliRunner() 6 | 7 | 8 | def test_version() -> None: 9 | result = runner.invoke(cli.app, ["--version"]) 10 | 11 | assert result.exit_code == 0 12 | assert f"{__app_name__} {__version__}\n" in result.stdout 13 | -------------------------------------------------------------------------------- /tests/ymls/depends_on/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | depends_on: 7 | db: 8 | condition: service_healthy 9 | redis: 10 | condition: service_started 11 | backend: 12 | image: awesome/backend 13 | depends_on: 14 | - db 15 | - redis 16 | db: 17 | image: mysql 18 | redis: 19 | image: redis 20 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Build and Publish to PyPi 15 | uses: JRubics/poetry-publish@v1.10 16 | with: 17 | pypi_token: ${{ secrets.PYPI_TOKEN }} 18 | extra_build_dependency_packages: "graphviz" 19 | -------------------------------------------------------------------------------- /compose_viz/models/extends.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class Extends: 5 | def __init__(self, service_name: str, from_file: Optional[str] = None): 6 | self._service_name = service_name 7 | self._from_file = from_file 8 | 9 | @property 10 | def service_name(self): 11 | return self._service_name 12 | 13 | @property 14 | def from_file(self): 15 | return self._from_file 16 | -------------------------------------------------------------------------------- /tests/ymls/ports/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | ports: 7 | - "3000" 8 | - "3000-3005" 9 | - "9090-9091:8080-8081" 10 | - "49100:22" 11 | - "127.0.0.1:8001:8001" 12 | - "127.0.0.1:5000-5010:5000-5010" 13 | - "6060:6060/udp" 14 | - ":7777" 15 | - "${BIND_IP:-127.0.0.1}:8080:8080" 16 | - target: 80 17 | host_ip: 127.0.0.1 18 | published: 8080 19 | protocol: tcp 20 | - target: 443 21 | -------------------------------------------------------------------------------- /tests/ymls/networks/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | frontend: 5 | image: awesome/frontend 6 | networks: 7 | - front-tier 8 | - back-tier 9 | 10 | monitoring: 11 | image: awesome/monitoring 12 | networks: 13 | - admin 14 | 15 | backend: 16 | image: awesome/backend 17 | networks: 18 | back-tier: 19 | aliases: 20 | - database 21 | admin: 22 | aliases: 23 | - mysql 24 | 25 | networks: 26 | front-tier: 27 | back-tier: 28 | admin: 29 | name: admin-network 30 | external: true 31 | -------------------------------------------------------------------------------- /compose_viz/models/device.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class Device: 5 | def __init__(self, host_path: str, container_path: str, cgroup_permissions: Optional[str] = None): 6 | self._host_path = host_path 7 | self._container_path = container_path 8 | self._cgroup_permissions = cgroup_permissions 9 | 10 | @property 11 | def host_path(self): 12 | return self._host_path 13 | 14 | @property 15 | def container_path(self): 16 | return self._container_path 17 | 18 | @property 19 | def cgroup_permissions(self): 20 | return self._cgroup_permissions 21 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from compose_viz.parser import Parser 4 | 5 | 6 | def test_parser_invalid_yaml() -> None: 7 | with pytest.raises(RuntimeError, match=r"Error parsing file 'tests/ymls/others/invalid.yml'.*"): 8 | Parser().parse("tests/ymls/others/invalid.yml") 9 | 10 | 11 | def test_parser_empty_yaml() -> None: 12 | with pytest.raises(RuntimeError, match=r"Error parsing file 'tests/ymls/others/empty.yml'.*"): 13 | Parser().parse("tests/ymls/others/empty.yml") 14 | 15 | 16 | def test_parser_no_services_found() -> None: 17 | with pytest.raises(AssertionError, match=r"No services found, aborting."): 18 | Parser().parse("tests/ymls/others/no-services.yml") 19 | -------------------------------------------------------------------------------- /tests/test_port.py: -------------------------------------------------------------------------------- 1 | from compose_viz.models.port import Port, Protocol 2 | 3 | 4 | def test_port_init_normal() -> None: 5 | try: 6 | p = Port(host_port="8080", container_port="80") 7 | 8 | assert p.host_port == "8080" 9 | assert p.container_port == "80" 10 | assert p.protocol == Protocol.any 11 | except Exception as e: 12 | assert False, e 13 | 14 | 15 | def test_port_with_protocol() -> None: 16 | try: 17 | p = Port(host_port="8080", container_port="80", protocol=Protocol.udp) 18 | 19 | assert p.host_port == "8080" 20 | assert p.container_port == "80" 21 | assert p.protocol == Protocol.udp 22 | except Exception as e: 23 | assert False, e 24 | -------------------------------------------------------------------------------- /tests/test_devices.py: -------------------------------------------------------------------------------- 1 | from compose_viz.models.device import Device 2 | 3 | 4 | def test_device_init_normal() -> None: 5 | try: 6 | d = Device(host_path="/dev/ttyUSB0", container_path="/dev/ttyUSB1") 7 | 8 | assert d.host_path == "/dev/ttyUSB0" 9 | assert d.container_path == "/dev/ttyUSB1" 10 | except Exception as e: 11 | assert False, e 12 | 13 | 14 | def test_device_with_cgroup_permissions() -> None: 15 | try: 16 | d = Device(host_path="/dev/sda1", container_path="/dev/xvda", cgroup_permissions="rwm") 17 | 18 | assert d.host_path == "/dev/sda1" 19 | assert d.container_path == "/dev/xvda" 20 | assert d.cgroup_permissions == "rwm" 21 | except Exception as e: 22 | assert False, e 23 | -------------------------------------------------------------------------------- /tests/ymls/volumes/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | backend: 5 | image: awesome/backend 6 | volumes: 7 | - "./data:/data" 8 | - type: bind 9 | source: /var/run/postgres/postgres.sock 10 | target: /var/run/postgres/postgres.sock 11 | common: 12 | image: busybox 13 | volumes: 14 | - common-volume:/var/lib/backup/data:rw,z 15 | cli: 16 | extends: 17 | service: common 18 | volumes: 19 | - cli-volume:/var/lib/backup/data:ro 20 | tmp: 21 | image: awesome/nginx 22 | volumes: 23 | - type: tmpfs 24 | target: /app 25 | 26 | volumes: 27 | common-volume: 28 | cli-volume: 29 | # https://docs.docker.com/reference/compose-file/volumes/#external 30 | external: true 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine3.16 as builder 2 | 3 | COPY ./ /compose-viz/ 4 | 5 | RUN \ 6 | apk update && \ 7 | pip install --upgrade pip 8 | 9 | RUN \ 10 | apk add binutils alpine-sdk libffi-dev 11 | 12 | RUN \ 13 | pip install poetry && \ 14 | pip install pyinstaller 15 | 16 | RUN \ 17 | cd /compose-viz && \ 18 | poetry config virtualenvs.create false && \ 19 | poetry install --no-root 20 | 21 | RUN \ 22 | cd /compose-viz && \ 23 | pyinstaller --onefile --name cpv ./compose_viz/__main__.py 24 | 25 | FROM alpine:3.16 as release 26 | 27 | COPY --from=builder /compose-viz/dist/cpv /usr/local/bin/cpv 28 | 29 | RUN \ 30 | apk add --no-cache graphviz ttf-droid 31 | 32 | VOLUME [ "/in" ] 33 | WORKDIR "/in" 34 | 35 | ENTRYPOINT [ "cpv" ] 36 | -------------------------------------------------------------------------------- /compose_viz/models/volume.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class VolumeType(str, Enum): 5 | volume = "volume" 6 | bind = "bind" 7 | tmpfs = "tmpfs" 8 | npipe = "npipe" 9 | 10 | 11 | class Volume: 12 | def __init__(self, source: str, target: str, type: VolumeType = VolumeType.volume, access_mode: str = "rw"): 13 | self._source = source 14 | self._target = target 15 | self._type = type 16 | self._access_mode = access_mode 17 | 18 | @property 19 | def source(self): 20 | return self._source 21 | 22 | @property 23 | def target(self): 24 | return self._target 25 | 26 | @property 27 | def type(self): 28 | return self._type 29 | 30 | @property 31 | def access_mode(self): 32 | return self._access_mode 33 | -------------------------------------------------------------------------------- /tests/test_extends.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from compose_viz.models.extends import Extends 4 | 5 | 6 | def test_extend_init_normal() -> None: 7 | try: 8 | e = Extends(service_name="frontend", from_file="tests/ymls/others/empty.yaml") 9 | 10 | assert e.service_name == "frontend" 11 | assert e.from_file == "tests/ymls/others/empty.yaml" 12 | except Exception as e: 13 | assert False, e 14 | 15 | 16 | def test_extend_init_without_from_file() -> None: 17 | try: 18 | e = Extends(service_name="frontend") 19 | 20 | assert e.service_name == "frontend" 21 | assert e.from_file is None 22 | except Exception as e: 23 | assert False, e 24 | 25 | 26 | def test_extend_init_without_service_name() -> None: 27 | with pytest.raises(TypeError): 28 | Extends(from_file="tests/ymls/others/empty.yaml") # type: ignore 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "compose-viz" 3 | version = "0.3.5" 4 | description = "A compose file visualization tool that supports compose-spec and allows you to gernerate graph in several formats." 5 | authors = ["Xyphuz Wu "] 6 | readme = "README.md" 7 | license = "MIT" 8 | homepage = "https://github.com/compose-viz/compose-viz" 9 | repository = "https://github.com/compose-viz/compose-viz" 10 | include = [ 11 | "LICENSE", 12 | ] 13 | 14 | [tool.poetry.dependencies] 15 | python = ">=3.9,<3.14" 16 | typer = "^0.16.0" 17 | graphviz = "^0.20" 18 | pydantic-yaml = "^1.3.0" 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | pytest = "^8.1.2" 22 | pre-commit = "^3.7.0" 23 | coverage = "^7.5.0" 24 | pytest-cov = "^5.0.0" 25 | datamodel-code-generator = "^0.25.6" 26 | 27 | [build-system] 28 | requires = ["poetry-core>=1.0.0"] 29 | build-backend = "poetry.core.masonry.api" 30 | 31 | [tool.poetry.scripts] 32 | cpv = "compose_viz.cli:start_cli" 33 | 34 | [tool.coverage.run] 35 | source = ["compose_viz"] 36 | omit = ["compose_viz/spec/*"] 37 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: | 2 | (?x)^( 3 | README.md| 4 | LICENSE| 5 | tests/ymls/others/ 6 | ) 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v6.0.0 10 | hooks: 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/pycqa/flake8 15 | rev: 7.3.0 16 | hooks: 17 | - id: flake8 18 | args: 19 | - "--max-line-length=512" 20 | - repo: https://github.com/pycqa/isort 21 | rev: 6.0.1 22 | hooks: 23 | - id: isort 24 | - repo: https://github.com/psf/black 25 | rev: 25.1.0 26 | hooks: 27 | - id: black 28 | args: # arguments to configure black 29 | - --line-length=512 30 | - repo: local 31 | hooks: 32 | - id: pyright 33 | name: pyright 34 | entry: pyright 35 | language: node 36 | pass_filenames: false 37 | types: [python] 38 | additional_dependencies: ['pyright@1.1.247'] 39 | -------------------------------------------------------------------------------- /compose_viz/models/port.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Protocol(str, Enum): 5 | tcp = "tcp" 6 | udp = "udp" 7 | any = "any" 8 | 9 | 10 | class AppProtocol(str, Enum): 11 | rest = "REST" 12 | mqtt = "MQTT" 13 | wbsock = "WebSocket" 14 | http = "http" 15 | https = "https" 16 | na = "NA" 17 | 18 | 19 | class Port: 20 | def __init__( 21 | self, 22 | host_port: str, 23 | container_port: str, 24 | protocol: Protocol = Protocol.any, 25 | app_protocol: AppProtocol = AppProtocol.na, 26 | ): 27 | self._host_port = host_port 28 | self._container_port = container_port 29 | self._protocol = protocol 30 | self._app_protocol = app_protocol 31 | 32 | @property 33 | def host_port(self): 34 | return self._host_port 35 | 36 | @property 37 | def container_port(self): 38 | return self._container_port 39 | 40 | @property 41 | def protocol(self): 42 | return self._protocol 43 | 44 | @property 45 | def app_protocol(self): 46 | return self._app_protocol 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 compose-viz 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. -------------------------------------------------------------------------------- /tests/test_volume.py: -------------------------------------------------------------------------------- 1 | from compose_viz.models.volume import Volume, VolumeType 2 | 3 | 4 | def test_volume_init_normal() -> None: 5 | try: 6 | v = Volume(source="./foo", target="./bar") 7 | 8 | assert v.source == "./foo" 9 | assert v.target == "./bar" 10 | assert v.type == VolumeType.volume 11 | assert v.access_mode == "rw" 12 | except Exception as e: 13 | assert False, e 14 | 15 | 16 | def test_volume_with_type() -> None: 17 | try: 18 | v = Volume(source="./foo", target="./bar", type=VolumeType.bind) 19 | 20 | assert v.source == "./foo" 21 | assert v.target == "./bar" 22 | assert v.type == VolumeType.bind 23 | assert v.access_mode == "rw" 24 | except Exception as e: 25 | assert False, e 26 | 27 | 28 | def test_volume_with_access_mode() -> None: 29 | try: 30 | v = Volume(source="./foo", target="./bar", access_mode="ro,z") 31 | 32 | assert v.source == "./foo" 33 | assert v.target == "./bar" 34 | assert v.type == VolumeType.volume 35 | assert v.access_mode == "ro,z" 36 | except Exception as e: 37 | assert False, e 38 | -------------------------------------------------------------------------------- /compose_viz/models/viz_formats.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class VizFormats(str, Enum): 5 | png = "png" 6 | dot = "dot" 7 | jpeg = "jpeg" 8 | json = "json" 9 | svg = "svg" 10 | 11 | bmp = "bmp" 12 | canon = "canon" 13 | cmap = "cmap" 14 | cmapx = "cmapx" 15 | cmapx_np = "cmapx_np" 16 | dot_json = "dot_json" 17 | emf = "emf" 18 | emfplus = "emfplus" 19 | eps = "eps" 20 | fig = "fig" 21 | gif = "gif" 22 | gv = "gv" 23 | imap = "imap" 24 | imap_np = "imap_np" 25 | ismap = "ismap" 26 | jpe = "jpe" 27 | jpg = "jpg" 28 | json0 = "json0" 29 | metafile = "metafile" 30 | mp = "mp" 31 | pdf = "pdf" 32 | pic = "pic" 33 | plain = "plain" 34 | plain_ext = "plain-ext" 35 | pov = "pov" 36 | ps = "ps" 37 | ps2 = "ps2" 38 | tif = "tif" 39 | tiff = "tiff" 40 | tk = "tk" 41 | vml = "vml" 42 | xdot = "xdot" 43 | xdot1_2 = "xdot1.2" 44 | xdot1_4 = "xdot1.4" 45 | xdot_json = "xdot_json" 46 | 47 | def __str__(self): 48 | # Python 3.11+ broken __str__ 49 | # https://github.com/python/cpython/issues/100458 50 | return self.name 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | branches: [ main, dev ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 15 | - uses: actions/checkout@v3 16 | 17 | - name: Switch to Current Branch 18 | run: git checkout ${{ env.BRANCH }} 19 | 20 | - run: | 21 | sudo apt-get update 22 | sudo apt-get install -y graphviz 23 | 24 | - name: Setup Python 3.10.4 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: '3.10.4' 28 | 29 | - name: Setup Poetry 30 | uses: abatilo/actions-poetry@v3 31 | with: 32 | poetry-version: 1.8.2 33 | 34 | - name: Install Dependencies 35 | run: | 36 | poetry install --no-root 37 | 38 | - name: Execute pre-commit 39 | run: | 40 | poetry run python -m pre_commit run --all-files --show-diff-on-failure 41 | 42 | - name: Run Pytest 43 | run: | 44 | poetry run python -m pytest --cov=compose_viz tests/ --tb=short 45 | -------------------------------------------------------------------------------- /tests/test_root_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typer.testing import CliRunner 4 | 5 | from compose_viz import cli 6 | 7 | runner = CliRunner() 8 | 9 | 10 | def test_root_service() -> None: 11 | input_path = "examples/voting-app/docker-compose.yml" 12 | output_filename = "compose-viz-test" 13 | default_format = "png" 14 | result = runner.invoke(cli.app, ["-r", "vote", "-o", output_filename, input_path]) 15 | 16 | assert result.exit_code == 0 17 | assert f"Successfully parsed {input_path}\n" in result.stdout 18 | assert os.path.exists(f"{output_filename}.{default_format}") 19 | 20 | os.remove(f"{output_filename}.{default_format}") 21 | 22 | 23 | def test_root_service_key_error() -> None: 24 | input_path = "examples/voting-app/docker-compose.yml" 25 | output_filename = "compose-viz-test" 26 | default_format = "png" 27 | result = runner.invoke(cli.app, ["-r", "not_exist_service", "-o", output_filename, input_path]) 28 | 29 | assert result.exit_code == 1 30 | assert result.exception is not None 31 | assert result.exception.args[0] == f"Service 'not_exist_service' not found in given compose file: '{input_path}'" 32 | assert not os.path.exists(f"{output_filename}.{default_format}") 33 | -------------------------------------------------------------------------------- /.github/workflows/release-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Set up QEMU 14 | uses: docker/setup-qemu-action@v2 15 | - 16 | name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v2 18 | - 19 | name: Login to DockerHub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | 25 | - name: Extract Tag Version 26 | id: tag_version 27 | run: | 28 | echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/v} 29 | 30 | - 31 | name: Build and push 32 | uses: docker/build-push-action@v3 33 | with: 34 | push: true 35 | tags: | 36 | ${{ secrets.DOCKERHUB_USERNAME }}/compose-viz:latest 37 | ${{ secrets.DOCKERHUB_USERNAME }}/compose-viz:${{ steps.tag_version.outputs.VERSION }} 38 | cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/compose-viz:buildcache 39 | cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/compose-viz:buildcache,mode=max 40 | -------------------------------------------------------------------------------- /.github/workflows/release-tagged-version.yml: -------------------------------------------------------------------------------- 1 | name: Release Tagged Version 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | id-token: "write" 10 | contents: "write" 11 | packages: "write" 12 | pull-requests: "read" 13 | 14 | jobs: 15 | tagged-release: 16 | runs-on: "ubuntu-latest" 17 | 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v3 21 | 22 | - name: Switch to Current Branch 23 | run: git checkout ${{ env.BRANCH }} 24 | 25 | - run: | 26 | sudo apt-get update 27 | sudo apt-get install -y graphviz 28 | 29 | - name: Setup Python 3.10.4 30 | uses: actions/setup-python@v3 31 | with: 32 | python-version: '3.10.4' 33 | 34 | - name: Setup Poetry 35 | uses: abatilo/actions-poetry@v3 36 | with: 37 | poetry-version: 1.8.2 38 | - run: | 39 | poetry install --no-root 40 | poetry build 41 | 42 | - name: "Release Tagged Version" 43 | uses: "marvinpinto/action-automatic-releases@latest" 44 | with: 45 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 46 | prerelease: false 47 | files: | 48 | LICENSE 49 | Dockerfile 50 | dist/** 51 | -------------------------------------------------------------------------------- /examples/non-normative/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | image: awesome/webapp 4 | networks: 5 | - front-tier 6 | - back-tier 7 | 8 | monitoring: 9 | env_file: 10 | - a.env 11 | - b.env 12 | container_name: monitoring-server 13 | image: awesome/monitoring 14 | networks: 15 | - admin 16 | expose: 17 | - 1234 18 | profiles: 19 | - tools 20 | - foo 21 | cgroup_parent: awesome-parent 22 | 23 | backend: 24 | networks: 25 | back-tier: 26 | aliases: 27 | - database 28 | admin: 29 | aliases: 30 | - mysql 31 | volumes: 32 | - type: volume 33 | source: db-data 34 | target: /data 35 | volume: 36 | nocopy: true 37 | - type: bind 38 | source: /var/run/postgres/postgres.sock 39 | target: /var/run/postgres/postgres.sock 40 | depends_on: 41 | - monitoring 42 | extends: 43 | service: frontend 44 | ports: 45 | - name: web-secured 46 | target: 443 47 | host_ip: 127.0.0.1 48 | published: "8083-9000" 49 | protocol: tcp 50 | app_protocol: wbsock 51 | mode : host 52 | links: 53 | - "db:database" 54 | cgroup_parent: awesome-parent 55 | db: 56 | image: postgres 57 | profiles: 58 | - foo 59 | devices: 60 | - "/dev/ttyUSB2:/dev/ttyUSB3" 61 | - "/dev/sda:/dev/xvda:rwm" 62 | 63 | networks: 64 | front-tier: 65 | back-tier: 66 | admin: 67 | volumes: 68 | db-data: 69 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from typer.testing import CliRunner 5 | 6 | from compose_viz import cli 7 | 8 | runner = CliRunner() 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "test_file_path", 13 | [ 14 | "tests/ymls/builds/docker-compose.yml", 15 | "tests/ymls/cgroup_parent/docker-compose.yml", 16 | "tests/ymls/container_name/docker-compose.yml", 17 | "tests/ymls/depends_on/docker-compose.yml", 18 | "tests/ymls/devices/docker-compose.yml", 19 | "tests/ymls/env_file/docker-compose.yml", 20 | "tests/ymls/expose/docker-compose.yml", 21 | "tests/ymls/extends/docker-compose.yml", 22 | "tests/ymls/links/docker-compose.yml", 23 | "tests/ymls/networks/docker-compose.yml", 24 | "tests/ymls/ports/docker-compose.yml", 25 | "tests/ymls/profiles/docker-compose.yml", 26 | "tests/ymls/volumes/docker-compose.yml", 27 | "examples/full-stack-node-app/docker-compose.yml", 28 | "examples/non-normative/docker-compose.yml", 29 | "examples/voting-app/docker-compose.yml", 30 | ], 31 | ) 32 | def test_cli(test_file_path: str) -> None: 33 | input_path = f"{test_file_path}" 34 | output_filename = "compose-viz-test" 35 | default_format = "png" 36 | result = runner.invoke(cli.app, ["-o", output_filename, input_path]) 37 | 38 | assert result.exit_code == 0 39 | assert f"Successfully parsed {input_path}\n" in result.stdout 40 | assert os.path.exists(f"{output_filename}.{default_format}") 41 | 42 | os.remove(f"{output_filename}.{default_format}") 43 | -------------------------------------------------------------------------------- /examples/full-stack-node-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | api: 5 | image: "awesome/api" 6 | extends: 7 | service: node 8 | file: common-services.yml 9 | build: 10 | args: 11 | PACKAGE_PATH: api 12 | WORKING_DIR: /usr/src/ 13 | expose: 14 | - 8000 15 | ports: 16 | - 8000:8000 17 | environment: 18 | - NODE_ENV=development 19 | volumes: 20 | - ./api:/usr/src 21 | depends_on: 22 | - db 23 | - adminer 24 | - redis 25 | networks: 26 | - front-tier 27 | - back-tier 28 | command: ["npm", "start"] 29 | frontend: 30 | extends: 31 | service: node 32 | file: common-services.yml 33 | build: 34 | args: 35 | PACKAGE_PATH: frontend 36 | WORKING_DIR: /usr/src/ 37 | expose: 38 | - 3000 39 | ports: 40 | - 3000:3000 41 | volumes: 42 | - ./frontend:/usr/src 43 | depends_on: 44 | - api 45 | networks: 46 | - front-tier 47 | command: ["npm", "start"] 48 | db: 49 | image: "awesome/db" 50 | extends: 51 | service: postgres 52 | file: common-services.yml 53 | restart: always 54 | networks: 55 | - back-tier 56 | volumes: 57 | - "db-data:/data" 58 | - type: bind 59 | source: /var/run/postgres/postgres.sock 60 | target: /var/run/postgres/postgres.sock 61 | redis: 62 | image: "awesome/redis" 63 | restart: always 64 | networks: 65 | - back-tier 66 | expose: 67 | - 6379 68 | adminer: 69 | image: "awesome/adminer" 70 | links: 71 | - db 72 | ports: 73 | - 8080:8080 74 | proxy: 75 | image: "awesome/proxy" 76 | build: 77 | context: . 78 | dockerfile: Dockerfile.proxy 79 | networks: 80 | - front-tier 81 | 82 | 83 | volumes: 84 | db-data: 85 | 86 | networks: 87 | front-tier: 88 | back-tier: 89 | -------------------------------------------------------------------------------- /update-submodules.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def revise_naming_convention(): 5 | name_mapping = { 6 | r"\bEnvFile1\b": "EnvFilePath", 7 | r"\bVolume1\b": "AdditionalVolumeOption", 8 | r"\[External\]": "[Union[bool, ExternalVolumeNetwork]]", 9 | r"\bExternal\b": "ExternalVolumeNetwork", 10 | r"\[External2\]": "[Union[bool, ExternalConfig]]", 11 | r"\bExternal2\b": "ExternalConfig", 12 | } 13 | 14 | with open("./compose_viz/spec/compose_spec.py", "r") as spec_file: 15 | spec_content = spec_file.read() 16 | 17 | for origin_name, new_name in name_mapping.items(): 18 | spec_content = re.sub(origin_name, new_name, spec_content) 19 | 20 | with open("./compose_viz/spec/compose_spec.py", "w") as spec_file: 21 | spec_file.write(spec_content) 22 | 23 | print("Revised naming convention successfully!") 24 | 25 | 26 | def update_version_number(): 27 | with open("./compose_viz/__init__.py", "r") as init_file: 28 | init_content = init_file.read() 29 | 30 | version_number = init_content.split(" ")[-1].replace('"', "").strip() 31 | major, minor, patch = version_number.split(".") 32 | new_version_number = f"{major}.{minor}.{int(patch) + 1}" 33 | 34 | new_init_content = f"""__app_name__ = "compose_viz" 35 | __version__ = "{new_version_number}" 36 | """ 37 | 38 | with open("./compose_viz/__init__.py", "w") as init_file: 39 | init_file.write(new_init_content) 40 | 41 | with open("./pyproject.toml", "r") as pyproject_file: 42 | pyproject_content = pyproject_file.read() 43 | 44 | pyproject_content = pyproject_content.replace(version_number, new_version_number) 45 | 46 | with open("./pyproject.toml", "w") as pyproject_file: 47 | pyproject_file.write(pyproject_content) 48 | 49 | print(f"Version number updated to {new_version_number} successfully!") 50 | 51 | 52 | if __name__ == "__main__": 53 | revise_naming_convention() 54 | # update_version_number() 55 | -------------------------------------------------------------------------------- /examples/voting-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/docker/labs/blob/master/beginner/chapters/votingapp.md 2 | 3 | version: "3.9" 4 | 5 | services: 6 | redis: 7 | image: redis:alpine 8 | ports: 9 | - "6379" 10 | networks: 11 | - frontend 12 | deploy: 13 | replicas: 2 14 | update_config: 15 | parallelism: 2 16 | delay: 10s 17 | restart_policy: 18 | condition: on-failure 19 | db: 20 | image: postgres:9.4 21 | volumes: 22 | - db-data:/var/lib/postgresql/data 23 | networks: 24 | - backend 25 | deploy: 26 | placement: 27 | constraints: [node.role == manager] 28 | vote: 29 | image: dockersamples/examplevotingapp_vote:before 30 | ports: 31 | - 5000:80 32 | networks: 33 | - frontend 34 | depends_on: 35 | - redis 36 | deploy: 37 | replicas: 2 38 | update_config: 39 | parallelism: 2 40 | restart_policy: 41 | condition: on-failure 42 | result: 43 | image: dockersamples/examplevotingapp_result:before 44 | ports: 45 | - 5001:80 46 | networks: 47 | - backend 48 | depends_on: 49 | - db 50 | deploy: 51 | replicas: 1 52 | update_config: 53 | parallelism: 2 54 | delay: 10s 55 | restart_policy: 56 | condition: on-failure 57 | worker: 58 | image: dockersamples/examplevotingapp_worker 59 | networks: 60 | - frontend 61 | - backend 62 | deploy: 63 | mode: replicated 64 | replicas: 1 65 | labels: [APP=VOTING] 66 | restart_policy: 67 | condition: on-failure 68 | delay: 10s 69 | max_attempts: 3 70 | window: 120s 71 | placement: 72 | constraints: [node.role == manager] 73 | visualizer: 74 | image: dockersamples/visualizer 75 | ports: 76 | - "8080:8080" 77 | stop_grace_period: 1m30s 78 | volumes: 79 | - /var/run/docker.sock:/var/run/docker.sock 80 | deploy: 81 | placement: 82 | constraints: [node.role == manager] 83 | 84 | networks: 85 | frontend: 86 | backend: 87 | 88 | volumes: 89 | db-data: 90 | -------------------------------------------------------------------------------- /compose_viz/cli.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import typer 4 | 5 | from compose_viz import __app_name__, __version__ 6 | from compose_viz.graph import Graph 7 | from compose_viz.models.viz_formats import VizFormats 8 | from compose_viz.parser import Parser 9 | 10 | app = typer.Typer( 11 | invoke_without_command=True, 12 | no_args_is_help=True, 13 | subcommand_metavar="", 14 | add_completion=False, 15 | ) 16 | 17 | 18 | def _version_callback(value: bool) -> None: 19 | if value: 20 | typer.echo(f"{__app_name__} {__version__}") 21 | raise typer.Exit() 22 | 23 | 24 | @app.callback() 25 | def compose_viz( 26 | input_path: str, 27 | output_filename: str = typer.Option( 28 | "compose-viz", 29 | "--output-filename", 30 | "-o", 31 | help="Output filename for the generated visualization file.", 32 | ), 33 | format: VizFormats = typer.Option( 34 | "png", 35 | "--format", 36 | "-m", 37 | help="Output format for the generated visualization file.", 38 | ), 39 | root_service: str = typer.Option( 40 | None, 41 | "--root-service", 42 | "-r", 43 | help="Root of the service tree (convenient for large compose yamls)", 44 | ), 45 | include_legend: bool = typer.Option( 46 | False, 47 | "--legend", 48 | "-l", 49 | help="Include a legend in the visualization.", 50 | ), 51 | no_ports: bool = typer.Option( 52 | False, 53 | "--no-ports", 54 | "-p", 55 | help="Don't show ports.", 56 | ), 57 | simple: bool = typer.Option( 58 | False, 59 | "--simple", 60 | "-s", 61 | help="Output a more simple graph with no image names and only basename of sources.", 62 | ), 63 | _: Optional[bool] = typer.Option( 64 | None, 65 | "--version", 66 | "-v", 67 | help="Show the version of compose-viz.", 68 | callback=_version_callback, 69 | is_eager=True, 70 | ), 71 | ) -> None: 72 | parser = Parser(no_ports, simple) 73 | compose = parser.parse(input_path, root_service=root_service) 74 | 75 | if compose: 76 | typer.echo(f"Successfully parsed {input_path}") 77 | 78 | Graph(compose, output_filename, include_legend, simple).render(format) 79 | 80 | 81 | def start_cli() -> None: 82 | app(prog_name="cpv") 83 | -------------------------------------------------------------------------------- /compose_viz/models/service.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from compose_viz.models.device import Device 4 | from compose_viz.models.extends import Extends 5 | from compose_viz.models.port import Port 6 | from compose_viz.models.volume import Volume 7 | 8 | 9 | class Service: 10 | def __init__( 11 | self, 12 | name: str, 13 | image: Optional[str] = None, 14 | ports: List[Port] = [], 15 | networks: List[str] = [], 16 | volumes: List[Volume] = [], 17 | depends_on: List[str] = [], 18 | links: List[str] = [], 19 | extends: Optional[Extends] = None, 20 | cgroup_parent: Optional[str] = None, 21 | container_name: Optional[str] = None, 22 | devices: List[Device] = [], 23 | env_file: List[str] = [], 24 | expose: List[str] = [], 25 | profiles: List[str] = [], 26 | ) -> None: 27 | self._name = name 28 | self._image = image 29 | self._ports = ports 30 | self._networks = networks 31 | self._volumes = volumes 32 | self._depends_on = depends_on 33 | self._links = links 34 | self._extends = extends 35 | self._cgroup_parent = cgroup_parent 36 | self._container_name = container_name 37 | self._devices = devices 38 | self._env_file = env_file 39 | self._expose = expose 40 | self._profiles = profiles 41 | 42 | @property 43 | def name(self): 44 | return self._name 45 | 46 | @property 47 | def image(self): 48 | return self._image 49 | 50 | @property 51 | def ports(self): 52 | return self._ports 53 | 54 | @property 55 | def networks(self): 56 | return self._networks 57 | 58 | @property 59 | def volumes(self): 60 | return self._volumes 61 | 62 | @property 63 | def depends_on(self): 64 | return self._depends_on 65 | 66 | @property 67 | def links(self): 68 | return self._links 69 | 70 | @property 71 | def extends(self): 72 | return self._extends 73 | 74 | @property 75 | def cgroup_parent(self): 76 | return self._cgroup_parent 77 | 78 | @property 79 | def container_name(self): 80 | return self._container_name 81 | 82 | @property 83 | def devices(self): 84 | return self._devices 85 | 86 | @property 87 | def env_file(self): 88 | return self._env_file 89 | 90 | @property 91 | def expose(self): 92 | return self._expose 93 | 94 | @property 95 | def profiles(self): 96 | return self._profiles 97 | -------------------------------------------------------------------------------- /.github/workflows/update-submodules.yml: -------------------------------------------------------------------------------- 1 | name: Update Submodules 2 | on: 3 | push: 4 | branches: [ dev ] 5 | schedule: 6 | - cron: '0 0 * * *' # Corrected cron syntax 7 | 8 | jobs: 9 | check_submodules: 10 | name: Check Submodules 11 | runs-on: ubuntu-latest 12 | outputs: 13 | has_changes: ${{ steps.check.outputs.has_changes }} 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 # Fetch all history for all branches and tags 19 | submodules: 'recursive' # Check out submodules recursively 20 | 21 | - name: Update Submodules 22 | run: | 23 | git submodule update --recursive --remote 24 | 25 | - name: Check for changes 26 | id: check 27 | run: | 28 | if [[ -n "$(git status --porcelain)" ]]; then 29 | echo "has_changes=true" >> $GITHUB_OUTPUT 30 | else 31 | echo "has_changes=false" >> $GITHUB_OUTPUT 32 | fi 33 | 34 | update_submodules: 35 | name: Update Submodules 36 | runs-on: ubuntu-latest 37 | needs: [check_submodules] 38 | if: needs.check_submodules.outputs.has_changes == 'true' 39 | steps: 40 | - name: Checkout Code 41 | uses: actions/checkout@v3 42 | with: 43 | fetch-depth: 0 44 | submodules: 'recursive' 45 | 46 | - name: Update Submodules 47 | run: | 48 | git submodule update --recursive --remote 49 | 50 | - name: Setup Python 3.10.4 51 | uses: actions/setup-python@v3 52 | with: 53 | python-version: '3.10.4' 54 | 55 | - name: Setup Poetry 56 | uses: abatilo/actions-poetry@v3 57 | with: 58 | poetry-version: 1.8.2 59 | 60 | - name: Install Dependencies 61 | run: | 62 | poetry install --no-root 63 | 64 | - name: Update Submodule 65 | run: | 66 | poetry run datamodel-codegen --input ./compose-spec/schema/compose-spec.json --output-model-type pydantic_v2.BaseModel --field-constraints --output ./compose_viz/spec/compose_spec.py 67 | poetry run python ./update-submodules.py 68 | 69 | - name: Execute pre-commit 70 | continue-on-error: true 71 | run: | 72 | poetry run python -m pre_commit run --all-files 73 | 74 | - name: Push changes 75 | run: | 76 | git config user.name github-actions 77 | git config user.email github-actions@github.com 78 | git checkout -b update-submodules-${{ github.run_id }} 79 | git add . 80 | git commit -m "chore: update submodules" 81 | git push --set-upstream origin update-submodules-${{ github.run_id }} 82 | 83 | - name: File PR 84 | uses: actions/github-script@v7 85 | with: 86 | github-token: ${{ secrets.GITHUB_TOKEN }} 87 | script: | 88 | await github.rest.pulls.create({ 89 | owner: context.repo.owner, 90 | repo: context.repo.repo, 91 | head: 'update-submodules-${{ github.run_id }}', 92 | base: 'dev', 93 | title: `chore: update submodules (${{ github.run_id }})`, 94 | body: `Please add the version tag to trigger the release.`, 95 | }); 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python ### 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | *.png 164 | !examples/**/*.png 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | [
![Contributors][contributors-shield]][contributors-url] 6 | [![Forks][forks-shield]][forks-url] 7 | [![Stargazers][stars-shield]][stars-url] 8 | [![MIT License][license-shield]][license-url] 9 | [![Issues][issues-shield]][issues-url] 10 | [![Issues Closed][issues-closed-shield]
][issues-closed-url] 11 | 12 |
13 | 14 | 15 | 16 | ![compose-viz](https://socialify.git.ci/compose-viz/compose-viz/image?description=1&font=KoHo&name=1&owner=1&pattern=Circuit%20Board&theme=Light) 17 | 18 |
19 |
20 |

21 | Explore Usage » 22 |
23 |
24 | Report Bug 25 | · 26 | Request Feature 27 |

28 |
29 | 30 | 31 | 32 |
33 | Table of Contents 34 |
    35 |
  1. 36 | About The Project 37 |
  2. 38 |
  3. 39 | Getting Started 40 | 47 |
  4. 48 |
  5. Roadmap
  6. 49 |
  7. Contributing
  8. 50 |
  9. License
  10. 51 |
  11. Contact
  12. 52 |
53 |
54 | 55 | 56 | 57 | ## About The Project 58 | 59 | `compose-viz` is a compose file visualization tool that follows [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/spec.md) and allows you to gernerate graph in several formats. 60 | 61 | If you are looking for a compose file vizualization tool, and you are using one of the [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/spec.md) implementations (e.g. [docker-compose](https://github.com/docker/compose)/[podman-compose](https://github.com/containers/podman-compose)), then `compose-viz` is a great choice for you. 62 | 63 |

(back to top)

64 | 65 | 66 | 67 | ## Getting Started 68 | 69 | ### Prerequisities 70 | 71 | #### Graphviz 72 | 73 | You need to install [Graphviz](https://graphviz.org/download/) to generate graphs. 74 | 75 | ### Installation 76 | 77 | #### Using `pip` 78 | 79 | ``` 80 | pip install compose-viz 81 | ``` 82 | 83 | #### Using `.whl` 84 | 85 | See [releases](https://github.com/compose-viz/compose-viz/releases). 86 | 87 | #### Docker Image 88 | 89 | See [wst24365888/compose-viz](https://hub.docker.com/r/wst24365888/compose-viz/tags). 90 | 91 | ### Example 92 | 93 | This example yml is from [docker compose beginner tutorial](https://github.com/docker/labs/blob/master/beginner/chapters/votingapp.md). 94 | 95 | ```bash 96 | cd examples/voting-app/ 97 | 98 | # using python script 99 | cpv -m svg docker-compose.yml 100 | 101 | # using docker image 102 | docker run --rm -it -v $(pwd):/in wst24365888/compose-viz -m svg docker-compose.yml 103 | 104 | # using docker image in powershell 105 | docker run --rm -it -v ${pwd}:/in wst24365888/compose-viz -m svg docker-compose.yml 106 | ``` 107 | 108 | And this is what the result looks like: 109 | 110 | ![compose-viz.svg](https://github.com/compose-viz/compose-viz/blob/main/examples/voting-app/compose-viz.svg) 111 | 112 | Check out the result [here](https://github.com/compose-viz/compose-viz/blob/main/examples/voting-app). 113 | 114 | ### Usage 115 | 116 | `cpv [OPTIONS] INPUT_PATH` 117 | 118 | ### Options 119 | 120 | | Option | Description | 121 | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 122 | | `-o, --output-filename FILENAME` | Output filename for the generated visualization file. [default: compose-viz] | 123 | | `-m, --format FORMAT` | Output format for the generated visualization file. See [supported formats](https://github.com/compose-viz/compose-viz/blob/main/compose_viz/models/viz_formats.py). [default: png] | 124 | | `-r, --root-service SERVICE_NAME` | Root of the service tree (convenient for large compose yamls) | 125 | | `-l, --legend` | Include a legend in the visualization. | 126 | | `-p, --no-ports` | Don't show ports. | 127 | | `-s, --simple` | Output a more simple graph with no image names and only basename of sources. | 128 | | `-v, --version` | Show the version of compose-viz. | 129 | | `--help` | Show help and exit. | 130 | 131 |

(back to top)

132 | 133 | 134 | 135 | ## Roadmap 136 | 137 | - [ ] Support more vizualization components. 138 | 139 | See the [open issues](https://github.com/compose-viz/compose-viz/issues) 140 | for a full list of proposed features (and known issues). 141 | 142 |

(back to top)

143 | 144 | 145 | 146 | ## Contributing 147 | 148 | Contributions are what make the open source community such an amazing place to 149 | learn, inspire, and create. Any contributions you make are **greatly 150 | appreciated**. 151 | 152 | If you have a suggestion that would make this better, please fork the repo and 153 | create a pull request. You can also simply open an issue with the tag 154 | "enhancement". Don't forget to give the project a star! Thanks again! 155 | 156 | 1. Fork the Project 157 | 2. Create your Feature Branch (`git checkout -b feat/amazing-feature`) 158 | 3. Commit your Changes with 159 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 160 | 4. Push to the Branch (`git push origin feat/amazing-feature`) 161 | 5. Open a Pull Request, **please select the `dev` branch as the target 162 | branch**. 163 | 164 |

(back to top)

165 | 166 | 167 | 168 | ## License 169 | 170 | Distributed under the MIT License. See 171 | [LICENSE](https://github.com/compose-viz/compose-viz/blob/main/LICENSE) 172 | for more information. 173 | 174 |

(back to top)

175 | 176 | 177 | 178 | ## Contact 179 | 180 | ### Author 181 | 182 | - HSING-HAN, WU (Xyphuz) 183 | - Mail me: xyphuzwu@gmail.com 184 | - About me: 185 | - GitHub: 186 | 187 | ### Project Link 188 | 189 | - 190 | 191 |

(back to top)

192 | 193 | [contributors-shield]: https://img.shields.io/github/contributors/compose-viz/compose-viz.svg?style=for-the-badge 194 | [contributors-url]: https://github.com/compose-viz/compose-viz/graphs/contributors 195 | [forks-shield]: https://img.shields.io/github/forks/compose-viz/compose-viz.svg?style=for-the-badge 196 | [forks-url]: https://github.com/compose-viz/compose-viz/network/members 197 | [stars-shield]: https://img.shields.io/github/stars/compose-viz/compose-viz.svg?style=for-the-badge 198 | [stars-url]: https://github.com/compose-viz/compose-viz/stargazers 199 | [issues-shield]: https://img.shields.io/github/issues/compose-viz/compose-viz.svg?style=for-the-badge 200 | [issues-url]: https://github.com/compose-viz/compose-viz/issues 201 | [issues-closed-shield]: https://img.shields.io/github/issues-closed/compose-viz/compose-viz.svg?style=for-the-badge 202 | [issues-closed-url]: https://github.com/compose-viz/compose-viz/issues?q=is%3Aissue+is%3Aclosed 203 | [license-shield]: https://img.shields.io/github/license/compose-viz/compose-viz.svg?style=for-the-badge 204 | [license-url]: https://github.com/compose-viz/compose-viz/blob/main/LICENSE 205 | -------------------------------------------------------------------------------- /compose_viz/graph.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional 3 | 4 | import graphviz 5 | 6 | from compose_viz.models.compose import Compose 7 | from compose_viz.models.port import AppProtocol, Protocol 8 | 9 | 10 | def apply_vertex_style(type: str) -> dict: 11 | style = { 12 | "service": { 13 | "shape": "component", 14 | }, 15 | "volume": { 16 | "shape": "cylinder", 17 | }, 18 | "network": { 19 | "shape": "pentagon", 20 | }, 21 | "port": { 22 | "shape": "circle", 23 | }, 24 | "env_file": { 25 | "shape": "tab", 26 | }, 27 | "porfile": { 28 | "shape": "invhouse", 29 | }, 30 | "cgroup": { 31 | "shape": "diamond", 32 | }, 33 | "device": { 34 | "shape": "box3d", 35 | }, 36 | } 37 | 38 | return style[type] 39 | 40 | 41 | def apply_edge_style(type: str) -> dict: 42 | style = { 43 | "exposes": { 44 | "style": "solid", 45 | "dir": "both", 46 | }, 47 | "links": { 48 | "style": "solid", 49 | }, 50 | "volumes_rw": { 51 | "style": "dashed", 52 | "dir": "both", 53 | }, 54 | "volumes_ro": { 55 | "style": "dashed", 56 | }, 57 | "depends_on": { 58 | "style": "dotted", 59 | }, 60 | "extends": { 61 | "dir": "both", 62 | "arrowhead": "inv", 63 | "arrowtail": "dot", 64 | }, 65 | "env_file": { 66 | "style": "solid", 67 | }, 68 | } 69 | 70 | return style[type] 71 | 72 | 73 | class Graph: 74 | def __init__(self, compose: Compose, filename: str, include_legend: bool, simple: bool) -> None: 75 | self.dot = graphviz.Digraph() 76 | self.dot.attr("graph", background="#ffffff", pad="0.5", ratio="fill") 77 | self.compose = compose 78 | self.filename = filename 79 | self.simple = simple 80 | 81 | if include_legend: 82 | self.dot.attr(rankdir="LR") 83 | 84 | with self.dot.subgraph(name="cluster_edge_") as edge: 85 | edge.attr(label="Edge") 86 | edge.node("line_0_l", style="invis") 87 | edge.node("line_0_r", style="invis") 88 | edge.edge( 89 | "line_0_l", 90 | "line_0_r", 91 | label="exposes", 92 | **apply_edge_style("exposes"), 93 | ) 94 | 95 | edge.node("line_1_l", style="invis") 96 | edge.node("line_1_r", style="invis") 97 | edge.edge("line_1_l", "line_1_r", label="links", **apply_edge_style("links")) 98 | 99 | edge.node("line_2_l", style="invis") 100 | edge.node("line_2_r", style="invis") 101 | edge.edge( 102 | "line_2_l", 103 | "line_2_r", 104 | label="volumes_rw", 105 | **apply_edge_style("volumes_rw"), 106 | ) 107 | 108 | edge.node("line_3_l", style="invis") 109 | edge.node("line_3_r", style="invis") 110 | edge.edge( 111 | "line_3_l", 112 | "line_3_r", 113 | label="volumes_ro", 114 | **apply_edge_style("volumes_ro"), 115 | ) 116 | 117 | edge.node("line_4_l", style="invis") 118 | edge.node("line_4_r", style="invis") 119 | edge.edge( 120 | "line_4_l", 121 | "line_4_r", 122 | label="depends_on", 123 | **apply_edge_style("depends_on"), 124 | ) 125 | 126 | edge.node("line_5_l", style="invis") 127 | edge.node("line_5_r", style="invis") 128 | edge.edge( 129 | "line_5_l", 130 | "line_5_r", 131 | label="extends", 132 | **apply_edge_style("extends"), 133 | ) 134 | 135 | with self.dot.subgraph(name="cluster_node_") as node: 136 | node_label = "Service" if simple else "Service\n(image)" 137 | node.attr(label="Node") 138 | node.node("service", shape="component", label=node_label) 139 | node.node("volume", shape="cylinder", label="Volume") 140 | node.node("network", shape="pentagon", label="Network") 141 | node.node("port", shape="circle", label="Port") 142 | node.node("env_file", shape="tab", label="Env File") 143 | node.node("profile", shape="invhouse", label="Profile") 144 | node.node("cgroup", shape="diamond", label="CGroupe") 145 | node.node("device", shape="box3d", label="Device") 146 | 147 | node.body.append("{ rank=source;service network env_file cgroup }") 148 | 149 | self.dot.node("inv", style="invis") 150 | self.dot.edge("inv", "network", style="invis") 151 | self.dot.edge("port", "line_2_l", style="invis") 152 | 153 | def validate_name(self, name: str) -> str: 154 | # graphviz does not allow ':' in node name 155 | transTable = name.maketrans({":": ""}) 156 | return name.translate(transTable) 157 | 158 | def add_vertex(self, name: str, type: str, lable: Optional[str] = None) -> None: 159 | self.dot.node(self.validate_name(name), lable, **apply_vertex_style(type)) 160 | 161 | def add_edge(self, head: str, tail: str, type: str, lable: Optional[str] = None) -> None: 162 | self.dot.edge( 163 | self.validate_name(head), 164 | self.validate_name(tail), 165 | lable, 166 | **apply_edge_style(type), 167 | ) 168 | 169 | def render(self, format: str, cleanup: bool = True) -> None: 170 | print("Starting graph rendering...") 171 | try: 172 | for service in self.compose.services: 173 | if service.image is not None: 174 | service_name = service.container_name if service.container_name else service.name 175 | node_label = f"{service_name}" if self.simple else f"{service_name}\n({service.image})" 176 | self.add_vertex( 177 | service.name, 178 | "service", 179 | lable=node_label, 180 | ) 181 | if service.extends is not None: 182 | self.add_vertex(service.name, "service", lable=f"{service.name}\n") 183 | self.add_edge(service.extends.service_name, service.name, "extends") 184 | if service.cgroup_parent is not None: 185 | self.add_vertex(service.cgroup_parent, "cgroup") 186 | self.add_edge(service.name, service.cgroup_parent, "links") 187 | 188 | for network in service.networks: 189 | self.add_vertex(network, "network", lable=f"net:{network}") 190 | self.add_edge(service.name, network, "links") 191 | for volume in service.volumes: 192 | self.add_vertex(volume.source, "volume") 193 | self.add_edge( 194 | service.name, 195 | volume.source, 196 | "volumes_rw" if "rw" in volume.access_mode else "volumes_ro", 197 | lable=volume.target, 198 | ) 199 | for expose in service.expose: 200 | self.add_vertex(expose, "port") 201 | self.add_edge(expose, service.name, "exposes") 202 | for port in service.ports: 203 | self.add_vertex(port.host_port, "port", lable=port.host_port) 204 | self.add_edge( 205 | port.host_port, 206 | service.name, 207 | "links", 208 | lable=port.container_port + (("/" + port.protocol.value) if port.protocol != Protocol.any else "") + (("\n(" + port.app_protocol.value + ")") if port.app_protocol != AppProtocol.na else ""), 209 | ) 210 | for env_file in service.env_file: 211 | self.add_vertex(env_file, "env_file") 212 | self.add_edge(env_file, service.name, "env_file") 213 | for link in service.links: 214 | if ":" in link: 215 | service_name, alias = link.split(":", 1) 216 | self.add_edge(service_name, service.name, "links", alias) 217 | else: 218 | self.add_edge(link, service.name, "links") 219 | for depends_on in service.depends_on: 220 | self.add_edge(service.name, depends_on, "depends_on") 221 | for porfile in service.profiles: 222 | self.add_vertex(porfile, "porfile") 223 | self.add_edge(service.name, porfile, "links") 224 | for device in service.devices: 225 | self.add_vertex(device.host_path, "device") 226 | self.add_edge( 227 | device.host_path, 228 | service.name, 229 | "exposes", 230 | f"{device.container_path}\n({device.cgroup_permissions})", 231 | ) 232 | except Exception as e: 233 | print(f"Error during graph rendering: {e}") 234 | raise 235 | 236 | if shutil.which("dot") is None: 237 | raise RuntimeError("Graphviz is not installed or not in your PATH.") 238 | 239 | print("Rendering graph to file...") 240 | self.dot.render(outfile=f"{self.filename}.{format}", format=format, cleanup=cleanup) 241 | print("Graph rendering complete.") 242 | -------------------------------------------------------------------------------- /examples/voting-app/compose-viz.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | redis 14 | 15 | 16 | 17 | redis 18 | (redis:alpine) 19 | 20 | 21 | 22 | frontend 23 | 24 | net:frontend 25 | 26 | 27 | 28 | redis->frontend 29 | 30 | 31 | 32 | 33 | 34 | 0.0.0.06379 35 | 36 | 0.0.0.0:6379 37 | 38 | 39 | 40 | 0.0.0.06379->redis 41 | 42 | 43 | 6379 44 | 45 | 46 | 47 | db 48 | 49 | 50 | 51 | db 52 | (postgres:9.4) 53 | 54 | 55 | 56 | backend 57 | 58 | net:backend 59 | 60 | 61 | 62 | db->backend 63 | 64 | 65 | 66 | 67 | 68 | db-data 69 | 70 | 71 | db-data 72 | 73 | 74 | 75 | db->db-data 76 | 77 | 78 | 79 | /var/lib/postgresql/data 80 | 81 | 82 | 83 | vote 84 | 85 | 86 | 87 | vote 88 | (dockersamples/examplevotingapp_vote:before) 89 | 90 | 91 | 92 | vote->redis 93 | 94 | 95 | 96 | 97 | 98 | vote->frontend 99 | 100 | 101 | 102 | 103 | 104 | 0.0.0.05000 105 | 106 | 0.0.0.0:5000 107 | 108 | 109 | 110 | 0.0.0.05000->vote 111 | 112 | 113 | 80 114 | 115 | 116 | 117 | result 118 | 119 | 120 | 121 | result 122 | (dockersamples/examplevotingapp_result:before) 123 | 124 | 125 | 126 | result->db 127 | 128 | 129 | 130 | 131 | 132 | result->backend 133 | 134 | 135 | 136 | 137 | 138 | 0.0.0.05001 139 | 140 | 0.0.0.0:5001 141 | 142 | 143 | 144 | 0.0.0.05001->result 145 | 146 | 147 | 80 148 | 149 | 150 | 151 | worker 152 | 153 | 154 | 155 | worker 156 | (dockersamples/examplevotingapp_worker) 157 | 158 | 159 | 160 | worker->frontend 161 | 162 | 163 | 164 | 165 | 166 | worker->backend 167 | 168 | 169 | 170 | 171 | 172 | visualizer 173 | 174 | 175 | 176 | visualizer 177 | (dockersamples/visualizer) 178 | 179 | 180 | 181 | /var/run/docker.sock 182 | 183 | 184 | /var/run/docker.sock 185 | 186 | 187 | 188 | visualizer->/var/run/docker.sock 189 | 190 | 191 | 192 | /var/run/docker.sock 193 | 194 | 195 | 196 | 0.0.0.08080 197 | 198 | 0.0.0.0:8080 199 | 200 | 201 | 202 | 0.0.0.08080->visualizer 203 | 204 | 205 | 8080 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /compose_viz/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os import path 3 | from typing import Any, Dict, List, Optional, Union 4 | 5 | from pydantic_yaml import parse_yaml_raw_as 6 | 7 | import compose_viz.spec.compose_spec as spec 8 | from compose_viz.models.compose import Compose, Service 9 | from compose_viz.models.device import Device 10 | from compose_viz.models.extends import Extends 11 | from compose_viz.models.port import AppProtocol, Port, Protocol 12 | from compose_viz.models.volume import Volume, VolumeType 13 | 14 | 15 | class Parser: 16 | def __init__(self, no_ports: bool = False, simple: bool = False): 17 | self.no_ports = no_ports 18 | self.simple = simple 19 | 20 | @staticmethod 21 | def _unwrap_depends_on( 22 | data_depends_on: Union[spec.ListOfStrings, Dict[Any, spec.DependsOn], None], 23 | ) -> List[str]: 24 | service_depends_on = [] 25 | if type(data_depends_on) is spec.ListOfStrings: 26 | service_depends_on = data_depends_on.root 27 | elif type(data_depends_on) is dict: 28 | for depends_on in data_depends_on.keys(): 29 | service_depends_on.append(str(depends_on)) 30 | return service_depends_on 31 | 32 | @staticmethod 33 | def compile_dependencies(service_name: str, services: Dict[Any, spec.Service], file_path: str) -> List[str]: 34 | assert service_name in services, f"Service '{service_name}' not found in given compose file: '{file_path}'" 35 | 36 | dependencies = [] 37 | for dependency in Parser._unwrap_depends_on(services[service_name].depends_on): 38 | if dependency: 39 | dependencies.append(dependency) 40 | dependencies.extend(Parser.compile_dependencies(dependency, services, file_path)) 41 | return dependencies 42 | 43 | def get_source(self, source: str): 44 | return path.basename(source) if self.simple else source 45 | 46 | def parse(self, file_path: str, root_service: Optional[str] = None) -> Compose: 47 | compose_data: spec.ComposeSpecification 48 | 49 | try: 50 | with open(file_path, "r") as file: 51 | file_content = file.read() 52 | compose_data = parse_yaml_raw_as(spec.ComposeSpecification, file_content) 53 | except Exception as e: 54 | raise RuntimeError(f"Error parsing file '{file_path}': {e}") 55 | 56 | services: List[Service] = [] 57 | 58 | assert compose_data.services is not None, "No services found, aborting." 59 | 60 | root_dependencies: List[str] = [] 61 | if root_service: 62 | root_dependencies = Parser.compile_dependencies(root_service, compose_data.services, file_path) 63 | root_dependencies.append(root_service) 64 | root_dependencies = list(set(root_dependencies)) 65 | 66 | for service_name, service_data in compose_data.services.items(): 67 | service_name = str(service_name) 68 | if root_service and service_name not in root_dependencies: 69 | continue 70 | 71 | service_image: Optional[str] = None 72 | if service_data.build is not None: 73 | if type(service_data.build) is str: 74 | service_image = f"build from '{service_data.build}'" 75 | elif type(service_data.build) is spec.Build: 76 | if service_data.build.context is not None and service_data.build.dockerfile is not None: 77 | service_image = f"build from '{service_data.build.context}' using '{service_data.build.dockerfile}'" 78 | elif service_data.build.context is not None: 79 | service_image = f"build from '{service_data.build.context}'" 80 | if service_data.image is not None: 81 | if service_image is not None: 82 | service_image += ", image: " + service_data.image 83 | else: 84 | service_image = service_data.image 85 | 86 | service_networks: List[str] = [] 87 | if service_data.networks is not None: 88 | if type(service_data.networks) is spec.ListOfStrings: 89 | service_networks = service_data.networks.root 90 | elif type(service_data.networks) is dict: 91 | service_networks = list(service_data.networks.keys()) 92 | 93 | service_extends: Optional[Extends] = None 94 | if service_data.extends is not None: 95 | # https://github.com/compose-spec/compose-spec/blob/master/spec.md#extends 96 | # The value of the extends key MUST be a dictionary. 97 | assert type(service_data.extends) is spec.Extends 98 | service_extends = Extends( 99 | service_name=service_data.extends.service, 100 | from_file=service_data.extends.file, 101 | ) 102 | 103 | service_ports: List[Port] = [] 104 | if service_data.ports is not None and not self.no_ports: 105 | for port_data in service_data.ports: 106 | host_ip: Optional[str] = None 107 | host_port: Optional[str] = None 108 | container_port: Optional[str] = None 109 | protocol: Optional[str] = None 110 | app_protocol: Optional[str] = None 111 | 112 | if type(port_data) is float: 113 | container_port = str(int(port_data)) 114 | host_port = f"0.0.0.0:{container_port}" 115 | elif type(port_data) is str: 116 | regex = r"((?P\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:|(\$\{([^}]+)\}):)|:|)?((?P\d+(\-\d+)?):)?((?P\d+(\-\d+)?))?(/(?P\w+))?" # noqa: E501 117 | match = re.match(regex, port_data) 118 | 119 | if match: 120 | host_ip = match.group("host_ip") 121 | host_port = match.group("host_port") 122 | container_port = match.group("container_port") 123 | protocol = match.group("protocol") 124 | 125 | assert container_port, "Invalid port format, aborting." 126 | 127 | if container_port is not None and host_port is None: 128 | host_port = container_port 129 | 130 | if host_ip is not None: 131 | host_port = f"{host_ip}{host_port}" 132 | else: 133 | host_port = f"0.0.0.0:{host_port}" 134 | elif type(port_data) is spec.Ports: 135 | assert port_data.target is not None, "Invalid port format, aborting." 136 | 137 | if type(port_data.published) is str or type(port_data.published) is int: 138 | host_port = str(port_data.published) 139 | 140 | if type(port_data.target) is int: 141 | container_port = str(port_data.target) 142 | 143 | host_ip = port_data.host_ip 144 | protocol = port_data.protocol 145 | app_protocol = port_data.app_protocol 146 | 147 | if container_port is not None and host_port is None: 148 | host_port = container_port 149 | 150 | if host_ip is not None: 151 | host_port = f"{host_ip}:{host_port}" 152 | else: 153 | host_port = f"0.0.0.0:{host_port}" 154 | 155 | assert host_port is not None, "Error while parsing port, aborting." 156 | assert container_port is not None, "Error while parsing port, aborting." 157 | 158 | if protocol is None: 159 | protocol = "any" 160 | 161 | if app_protocol is None: 162 | app_protocol = "na" 163 | 164 | service_ports.append( 165 | Port( 166 | host_port=host_port, 167 | container_port=container_port, 168 | protocol=Protocol[protocol], 169 | app_protocol=AppProtocol[app_protocol], 170 | ) 171 | ) 172 | 173 | service_depends_on: List[str] = [] 174 | if service_data.depends_on is not None: 175 | service_depends_on = Parser._unwrap_depends_on(service_data.depends_on) 176 | 177 | service_volumes: List[Volume] = [] 178 | if service_data.volumes is not None: 179 | for volume_data in service_data.volumes: 180 | if type(volume_data) is str: 181 | assert ":" in volume_data, "Invalid volume input, aborting." 182 | 183 | split_data = volume_data.split(":") 184 | source = self.get_source(split_data[0]) 185 | if len(split_data) == 2: 186 | service_volumes.append(Volume(source=source, target=split_data[1])) 187 | elif len(split_data) == 3: 188 | service_volumes.append( 189 | Volume( 190 | source=source, 191 | target=split_data[1], 192 | access_mode=split_data[2], 193 | ) 194 | ) 195 | elif type(volume_data) is spec.Volumes: 196 | assert volume_data.target is not None, "Invalid volume input, aborting." 197 | 198 | # https://github.com/compose-spec/compose-spec/blob/master/spec.md#long-syntax-4 199 | # `volume_data.source` is not applicable for a tmpfs mount. 200 | if volume_data.source is None: 201 | volume_data.source = volume_data.target 202 | 203 | assert volume_data.source is not None 204 | source = self.get_source(volume_data.source) 205 | 206 | service_volumes.append( 207 | Volume( 208 | source=source, 209 | target=volume_data.target, 210 | type=VolumeType[volume_data.type.value], 211 | ) 212 | ) 213 | 214 | service_links: List[str] = [] 215 | if service_data.links is not None: 216 | service_links = service_data.links 217 | 218 | cgroup_parent: Optional[str] = None 219 | if service_data.cgroup_parent is not None: 220 | cgroup_parent = service_data.cgroup_parent 221 | 222 | container_name: Optional[str] = None 223 | if service_data.container_name is not None: 224 | container_name = service_data.container_name 225 | 226 | env_file: List[str] = [] 227 | if service_data.env_file is not None: 228 | if type(service_data.env_file.root) is str: 229 | env_file = [service_data.env_file.root] 230 | elif type(service_data.env_file.root) is list: 231 | for env_file_data in service_data.env_file.root: 232 | if type(env_file_data) is str: 233 | env_file.append(env_file_data) 234 | elif type(env_file_data) is spec.EnvFilePath: 235 | env_file.append(env_file_data.path) 236 | else: 237 | print(f"Invalid env_file data: {service_data.env_file.root}") 238 | 239 | expose: List[str] = [] 240 | if service_data.expose is not None: 241 | for port in service_data.expose: 242 | # to avoid to have values like 8885.0 for instance, cast floats into int first 243 | port_str = str(int(port)) if isinstance(port, float) else str(port) 244 | expose.append(port_str) 245 | 246 | profiles: List[str] = [] 247 | if service_data.profiles is not None: 248 | if type(service_data.profiles) is spec.ListOfStrings: 249 | profiles = service_data.profiles.root 250 | 251 | devices: List[Device] = [] 252 | if service_data.devices is not None: 253 | for device_data in service_data.devices: 254 | if type(device_data) is str: 255 | assert ":" in device_data, "Invalid volume input, aborting." 256 | 257 | split_data = device_data.split(":") 258 | if len(split_data) == 2: 259 | devices.append( 260 | Device( 261 | host_path=split_data[0], 262 | container_path=split_data[1], 263 | ) 264 | ) 265 | elif len(split_data) == 3: 266 | devices.append( 267 | Device( 268 | host_path=split_data[0], 269 | container_path=split_data[1], 270 | cgroup_permissions=split_data[2], 271 | ) 272 | ) 273 | 274 | services.append( 275 | Service( 276 | name=service_name, 277 | image=service_image, 278 | networks=service_networks, 279 | extends=service_extends, 280 | ports=service_ports, 281 | depends_on=service_depends_on, 282 | volumes=service_volumes, 283 | links=service_links, 284 | cgroup_parent=cgroup_parent, 285 | container_name=container_name, 286 | env_file=env_file, 287 | expose=expose, 288 | profiles=profiles, 289 | devices=devices, 290 | ) 291 | ) 292 | 293 | return Compose(services=services) 294 | -------------------------------------------------------------------------------- /tests/test_parse_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from compose_viz.models.compose import Compose 4 | from compose_viz.models.device import Device 5 | from compose_viz.models.extends import Extends 6 | from compose_viz.models.port import Port, Protocol 7 | from compose_viz.models.service import Service 8 | from compose_viz.models.volume import Volume, VolumeType 9 | from compose_viz.parser import Parser 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "test_file_path, expected", 14 | [ 15 | ( 16 | "builds/docker-compose", 17 | Compose( 18 | services=[ 19 | Service( 20 | name="frontend", 21 | image="build from './frontend', image: awesome/frontend", 22 | ), 23 | Service( 24 | name="backend", 25 | image="build from 'backend' using '../backend.Dockerfile'", 26 | ), 27 | Service( 28 | name="db", 29 | image="build from './db'", 30 | ), 31 | ], 32 | ), 33 | ), 34 | ( 35 | "depends_on/docker-compose", 36 | Compose( 37 | services=[ 38 | Service( 39 | name="frontend", 40 | image="awesome/frontend", 41 | depends_on=[ 42 | "db", 43 | "redis", 44 | ], 45 | ), 46 | Service( 47 | name="backend", 48 | image="awesome/backend", 49 | depends_on=[ 50 | "db", 51 | "redis", 52 | ], 53 | ), 54 | Service( 55 | name="db", 56 | image="mysql", 57 | ), 58 | Service( 59 | name="redis", 60 | image="redis", 61 | ), 62 | ], 63 | ), 64 | ), 65 | ( 66 | "extends/docker-compose", 67 | Compose( 68 | services=[ 69 | Service( 70 | name="base", 71 | image="alpine:latest", 72 | ), 73 | Service( 74 | name="derive_from_base", 75 | image="alpine:edge", 76 | extends=Extends( 77 | service_name="base", 78 | ), 79 | ), 80 | Service( 81 | name="derive_from_file", 82 | extends=Extends( 83 | service_name="web", 84 | from_file="web.yml", 85 | ), 86 | ), 87 | ], 88 | ), 89 | ), 90 | ( 91 | "links/docker-compose", 92 | Compose( 93 | services=[ 94 | Service( 95 | name="frontend", 96 | image="awesome/frontend", 97 | links=[ 98 | "db:database", 99 | ], 100 | ), 101 | Service( 102 | name="db", 103 | image="mysql", 104 | ), 105 | ], 106 | ), 107 | ), 108 | ( 109 | "networks/docker-compose", 110 | Compose( 111 | services=[ 112 | Service( 113 | name="frontend", 114 | image="awesome/frontend", 115 | networks=[ 116 | "front-tier", 117 | "back-tier", 118 | ], 119 | ), 120 | Service( 121 | name="monitoring", 122 | image="awesome/monitoring", 123 | networks=[ 124 | "admin", 125 | ], 126 | ), 127 | Service( 128 | name="backend", 129 | image="awesome/backend", 130 | networks=[ 131 | "back-tier", 132 | "admin", 133 | ], 134 | ), 135 | ], 136 | ), 137 | ), 138 | ( 139 | "ports/docker-compose", 140 | Compose( 141 | services=[ 142 | Service( 143 | name="frontend", 144 | image="awesome/frontend", 145 | ports=[ 146 | Port( 147 | host_port="0.0.0.0:3000", 148 | container_port="3000", 149 | ), 150 | Port( 151 | host_port="0.0.0.0:3000-3005", 152 | container_port="3000-3005", 153 | ), 154 | Port( 155 | host_port="0.0.0.0:9090-9091", 156 | container_port="8080-8081", 157 | ), 158 | Port( 159 | host_port="0.0.0.0:49100", 160 | container_port="22", 161 | ), 162 | Port( 163 | host_port="127.0.0.1:8001", 164 | container_port="8001", 165 | ), 166 | Port( 167 | host_port="127.0.0.1:5000-5010", 168 | container_port="5000-5010", 169 | ), 170 | Port( 171 | host_port="0.0.0.0:6060", 172 | container_port="6060", 173 | protocol=Protocol.udp, 174 | ), 175 | Port( 176 | host_port="0.0.0.0:7777", 177 | container_port="7777", 178 | ), 179 | Port( 180 | host_port="${BIND_IP:-127.0.0.1}:8080", 181 | container_port="8080", 182 | ), 183 | Port( 184 | host_port="127.0.0.1:8080", 185 | container_port="80", 186 | protocol=Protocol.tcp, 187 | ), 188 | Port( 189 | host_port="0.0.0.0:443", 190 | container_port="443", 191 | ), 192 | ], 193 | ), 194 | ], 195 | ), 196 | ), 197 | ( 198 | "volumes/docker-compose", 199 | Compose( 200 | services=[ 201 | Service( 202 | name="backend", 203 | image="awesome/backend", 204 | volumes=[ 205 | Volume( 206 | source="./data", 207 | target="/data", 208 | ), 209 | Volume( 210 | source="/var/run/postgres/postgres.sock", 211 | target="/var/run/postgres/postgres.sock", 212 | type=VolumeType.bind, 213 | ), 214 | ], 215 | ), 216 | Service( 217 | name="common", 218 | image="busybox", 219 | volumes=[ 220 | Volume( 221 | source="common-volume", 222 | target="/var/lib/backup/data", 223 | ), 224 | ], 225 | ), 226 | Service( 227 | name="cli", 228 | extends=Extends( 229 | service_name="common", 230 | ), 231 | volumes=[ 232 | Volume( 233 | source="cli-volume", 234 | target="/var/lib/backup/data", 235 | access_mode="ro,z", 236 | ), 237 | ], 238 | ), 239 | Service( 240 | name="tmp", 241 | image="awesome/nginx", 242 | volumes=[ 243 | Volume( 244 | source="/app", 245 | target="/app", 246 | type=VolumeType.tmpfs, 247 | ), 248 | ], 249 | ), 250 | ], 251 | ), 252 | ), 253 | ( 254 | "cgroup_parent/docker-compose", 255 | Compose( 256 | services=[ 257 | Service( 258 | name="frontend", 259 | image="awesome/frontend", 260 | cgroup_parent="system", 261 | ), 262 | ], 263 | ), 264 | ), 265 | ( 266 | "container_name/docker-compose", 267 | Compose( 268 | services=[ 269 | Service( 270 | name="frontend", 271 | image="awesome/frontend", 272 | container_name="myfrontend", 273 | ), 274 | ], 275 | ), 276 | ), 277 | ( 278 | "env_file/docker-compose", 279 | Compose( 280 | services=[ 281 | Service( 282 | name="frontend", 283 | image="awesome/frontend", 284 | env_file=["a.env"], 285 | ), 286 | Service( 287 | name="backend", 288 | image="awesome/backend", 289 | env_file=["b.env"], 290 | ), 291 | Service( 292 | name="db", 293 | image="awesome/db", 294 | env_file=["c.env", "d.env"], 295 | ), 296 | ], 297 | ), 298 | ), 299 | ( 300 | "expose/docker-compose", 301 | Compose( 302 | services=[ 303 | Service( 304 | name="frontend", 305 | image="awesome/frontend", 306 | expose=["27118"], 307 | ), 308 | Service( 309 | name="backend", 310 | image="awesome/backend", 311 | expose=["27017", "27018"], 312 | ), 313 | ], 314 | ), 315 | ), 316 | ( 317 | "profiles/docker-compose", 318 | Compose( 319 | services=[ 320 | Service( 321 | name="frontend", 322 | image="awesome/frontend", 323 | profiles=["frontend"], 324 | ), 325 | Service( 326 | name="phpmyadmin", 327 | image="phpmyadmin", 328 | profiles=["debug"], 329 | ), 330 | Service( 331 | name="db", 332 | image="awesome/db", 333 | profiles=["db", "sql"], 334 | ), 335 | ], 336 | ), 337 | ), 338 | ( 339 | "devices/docker-compose", 340 | Compose( 341 | services=[ 342 | Service( 343 | name="frontend", 344 | image="awesome/frontend", 345 | devices=[ 346 | Device( 347 | host_path="/dev/ttyUSB0", 348 | container_path="/dev/ttyUSB1", 349 | ) 350 | ], 351 | ), 352 | Service( 353 | name="backend", 354 | image="awesome/backend", 355 | devices=[ 356 | Device( 357 | host_path="/dev/ttyUSB2", 358 | container_path="/dev/ttyUSB3", 359 | ), 360 | Device( 361 | host_path="/dev/sda", 362 | container_path="/dev/xvda", 363 | cgroup_permissions="rwm", 364 | ), 365 | ], 366 | ), 367 | ], 368 | ), 369 | ), 370 | ], 371 | ) 372 | def test_parse_file(test_file_path: str, expected: Compose) -> None: 373 | parser = Parser() 374 | actual = parser.parse(f"tests/ymls/{test_file_path}.yml") 375 | 376 | assert len(actual.services) == len(expected.services) 377 | 378 | for actual_service, expected_service in zip(actual.services, expected.services): 379 | assert actual_service.name == expected_service.name 380 | assert actual_service.image == expected_service.image 381 | 382 | assert len(actual_service.ports) == len(expected_service.ports) 383 | for actual_port, expected_port in zip(actual_service.ports, expected_service.ports): 384 | assert actual_port.host_port == expected_port.host_port 385 | assert actual_port.container_port == expected_port.container_port 386 | assert actual_port.protocol == expected_port.protocol 387 | 388 | assert actual_service.networks == expected_service.networks 389 | 390 | assert len(actual_service.volumes) == len(expected_service.volumes) 391 | for actual_volume, expected_volume in zip(actual_service.volumes, expected_service.volumes): 392 | assert actual_volume.source == expected_volume.source 393 | assert actual_volume.target == expected_volume.target 394 | assert actual_volume.type == expected_volume.type 395 | 396 | assert actual_service.depends_on == expected_service.depends_on 397 | assert actual_service.links == expected_service.links 398 | 399 | assert (actual_service.extends is not None) == (expected_service.extends is not None) 400 | 401 | if (actual_service.extends is not None) and (expected_service.extends is not None): 402 | assert actual_service.extends.service_name == expected_service.extends.service_name 403 | assert actual_service.extends.from_file == expected_service.extends.from_file 404 | 405 | assert actual_service.cgroup_parent == expected_service.cgroup_parent 406 | assert actual_service.container_name == expected_service.container_name 407 | 408 | assert actual_service.expose == expected_service.expose 409 | assert actual_service.env_file == expected_service.env_file 410 | assert actual_service.profiles == expected_service.profiles 411 | 412 | assert len(actual_service.devices) == len(expected_service.devices) 413 | for actual_device, expected_device in zip(actual_service.devices, expected_service.devices): 414 | assert actual_device.host_path == expected_device.host_path 415 | assert actual_device.container_path == expected_device.container_path 416 | assert actual_device.cgroup_permissions == expected_device.cgroup_permissions 417 | -------------------------------------------------------------------------------- /compose_viz/spec/compose_spec.py: -------------------------------------------------------------------------------- 1 | # generated by datamodel-codegen: 2 | # filename: compose-spec.json 3 | # timestamp: 2025-08-10T01:49:12+00:00 4 | 5 | from __future__ import annotations 6 | 7 | from enum import Enum 8 | from typing import Any, Dict, List, Optional, Union 9 | 10 | from pydantic import BaseModel, ConfigDict, Field, RootModel 11 | 12 | 13 | class Cgroup(Enum): 14 | host = "host" 15 | private = "private" 16 | 17 | 18 | class CpuCount(RootModel[int]): 19 | root: int = Field(..., description="Number of usable CPUs.", ge=0) 20 | 21 | 22 | class CpuPercent(RootModel[int]): 23 | root: int = Field(..., description="Percentage of CPU resources to use.", ge=0, le=100) 24 | 25 | 26 | class CredentialSpec(BaseModel): 27 | model_config = ConfigDict( 28 | extra="forbid", 29 | ) 30 | config: Optional[str] = Field(None, description="The name of the credential spec Config to use.") 31 | file: Optional[str] = Field(None, description="Path to a credential spec file.") 32 | registry: Optional[str] = Field(None, description="Path to a credential spec in the Windows registry.") 33 | 34 | 35 | class Condition(Enum): 36 | service_started = "service_started" 37 | service_healthy = "service_healthy" 38 | service_completed_successfully = "service_completed_successfully" 39 | 40 | 41 | class DependsOn(BaseModel): 42 | model_config = ConfigDict( 43 | extra="forbid", 44 | ) 45 | restart: Optional[Union[bool, str]] = Field( 46 | None, 47 | description="Whether to restart dependent services when this service is restarted.", 48 | ) 49 | required: Optional[bool] = Field( 50 | True, 51 | description="Whether the dependency is required for the dependent service to start.", 52 | ) 53 | condition: Condition = Field( 54 | ..., 55 | description="Condition to wait for. 'service_started' waits until the service has started, 'service_healthy' waits until the service is healthy (as defined by its healthcheck), 'service_completed_successfully' waits until the service has completed successfully.", 56 | ) 57 | 58 | 59 | class Devices(BaseModel): 60 | model_config = ConfigDict( 61 | extra="forbid", 62 | ) 63 | source: str = Field(..., description="Path on the host to the device.") 64 | target: Optional[str] = Field(None, description="Path in the container where the device will be mapped.") 65 | permissions: Optional[str] = Field(None, description="Cgroup permissions for the device (rwm).") 66 | 67 | 68 | class Extends(BaseModel): 69 | model_config = ConfigDict( 70 | extra="forbid", 71 | ) 72 | service: str = Field(..., description="The name of the service to extend.") 73 | file: Optional[str] = Field(None, description="The file path where the service to extend is defined.") 74 | 75 | 76 | class Provider(BaseModel): 77 | model_config = ConfigDict( 78 | extra="forbid", 79 | ) 80 | type: str = Field( 81 | ..., 82 | description="ExternalVolumeNetwork component used by Compose to manage setup and teardown lifecycle of the service.", 83 | ) 84 | options: Optional[Dict[str, Union[Union[str, float, bool], List[Union[str, float, bool]]]]] = Field(None, description="Provider-specific options.") 85 | 86 | 87 | class Logging(BaseModel): 88 | model_config = ConfigDict( 89 | extra="forbid", 90 | ) 91 | driver: Optional[str] = Field( 92 | None, 93 | description="Logging driver to use, such as 'json-file', 'syslog', 'journald', etc.", 94 | ) 95 | options: Optional[Dict[str, Optional[Union[str, float]]]] = Field(None, description="Options for the logging driver.") 96 | 97 | 98 | class Models(BaseModel): 99 | model_config = ConfigDict( 100 | extra="forbid", 101 | ) 102 | endpoint_var: Optional[str] = Field(None, description="Environment variable set to AI model endpoint.") 103 | model_var: Optional[str] = Field(None, description="Environment variable set to AI model name.") 104 | 105 | 106 | class OomScoreAdj(RootModel[int]): 107 | root: int = Field( 108 | ..., 109 | description="Tune host's OOM preferences for the container (accepts -1000 to 1000).", 110 | ge=-1000, 111 | le=1000, 112 | ) 113 | 114 | 115 | class Ports(BaseModel): 116 | model_config = ConfigDict( 117 | extra="forbid", 118 | ) 119 | name: Optional[str] = Field(None, description="A human-readable name for this port mapping.") 120 | mode: Optional[str] = Field( 121 | None, 122 | description="The port binding mode, either 'host' for publishing a host port or 'ingress' for load balancing.", 123 | ) 124 | host_ip: Optional[str] = Field(None, description="The host IP to bind to.") 125 | target: Optional[Union[int, str]] = Field(None, description="The port inside the container.") 126 | published: Optional[Union[str, int]] = Field(None, description="The publicly exposed port.") 127 | protocol: Optional[str] = Field(None, description="The port protocol (tcp or udp).") 128 | app_protocol: Optional[str] = Field( 129 | None, 130 | description="Application protocol to use with the port (e.g., http, https, mysql).", 131 | ) 132 | 133 | 134 | class Type(Enum): 135 | bind = "bind" 136 | volume = "volume" 137 | tmpfs = "tmpfs" 138 | cluster = "cluster" 139 | npipe = "npipe" 140 | image = "image" 141 | 142 | 143 | class Recursive(Enum): 144 | enabled = "enabled" 145 | disabled = "disabled" 146 | writable = "writable" 147 | readonly = "readonly" 148 | 149 | 150 | class Selinux(Enum): 151 | z = "z" 152 | Z = "Z" 153 | 154 | 155 | class Bind(BaseModel): 156 | model_config = ConfigDict( 157 | extra="forbid", 158 | ) 159 | propagation: Optional[str] = Field( 160 | None, 161 | description="The propagation mode for the bind mount: 'shared', 'slave', 'private', 'rshared', 'rslave', or 'rprivate'.", 162 | ) 163 | create_host_path: Optional[Union[bool, str]] = Field(None, description="Create the host path if it doesn't exist.") 164 | recursive: Optional[Recursive] = Field(None, description="Recursively mount the source directory.") 165 | selinux: Optional[Selinux] = Field( 166 | None, 167 | description="SELinux relabeling options: 'z' for shared content, 'Z' for private unshared content.", 168 | ) 169 | 170 | 171 | class Size(RootModel[int]): 172 | root: int = Field(..., description="Size of the tmpfs mount in bytes.", ge=0) 173 | 174 | 175 | class Tmpfs(BaseModel): 176 | model_config = ConfigDict( 177 | extra="forbid", 178 | ) 179 | size: Optional[Union[Size, str]] = Field(None, description="Size of the tmpfs mount in bytes.") 180 | mode: Optional[Union[float, str]] = Field(None, description="File mode of the tmpfs in octal.") 181 | 182 | 183 | class Image(BaseModel): 184 | model_config = ConfigDict( 185 | extra="forbid", 186 | ) 187 | subpath: Optional[str] = Field(None, description="Path within the image to mount instead of the image root.") 188 | 189 | 190 | class Healthcheck(BaseModel): 191 | model_config = ConfigDict( 192 | extra="forbid", 193 | ) 194 | disable: Optional[Union[bool, str]] = Field( 195 | None, 196 | description="Disable any container-specified healthcheck. Set to true to disable.", 197 | ) 198 | interval: Optional[str] = Field( 199 | None, 200 | description="Time between running the check (e.g., '1s', '1m30s'). Default: 30s.", 201 | ) 202 | retries: Optional[Union[float, str]] = Field( 203 | None, 204 | description="Number of consecutive failures needed to consider the container as unhealthy. Default: 3.", 205 | ) 206 | test: Optional[Union[str, List[str]]] = Field( 207 | None, 208 | description="The test to perform to check container health. Can be a string or a list. The first item is either NONE, CMD, or CMD-SHELL. If it's CMD, the rest of the command is exec'd. If it's CMD-SHELL, the rest is run in the shell.", 209 | ) 210 | timeout: Optional[str] = Field( 211 | None, 212 | description="Maximum time to allow one check to run (e.g., '1s', '1m30s'). Default: 30s.", 213 | ) 214 | start_period: Optional[str] = Field( 215 | None, 216 | description="Start period for the container to initialize before starting health-retries countdown (e.g., '1s', '1m30s'). Default: 0s.", 217 | ) 218 | start_interval: Optional[str] = Field( 219 | None, 220 | description="Time between running the check during the start period (e.g., '1s', '1m30s'). Default: interval value.", 221 | ) 222 | 223 | 224 | class Action(Enum): 225 | rebuild = "rebuild" 226 | sync = "sync" 227 | restart = "restart" 228 | sync_restart = "sync+restart" 229 | sync_exec = "sync+exec" 230 | 231 | 232 | class Order(Enum): 233 | start_first = "start-first" 234 | stop_first = "stop-first" 235 | 236 | 237 | class RollbackConfig(BaseModel): 238 | model_config = ConfigDict( 239 | extra="forbid", 240 | ) 241 | parallelism: Optional[Union[int, str]] = Field( 242 | None, 243 | description="The number of containers to rollback at a time. If set to 0, all containers rollback simultaneously.", 244 | ) 245 | delay: Optional[str] = Field( 246 | None, 247 | description="The time to wait between each container group's rollback (e.g., '1s', '1m30s').", 248 | ) 249 | failure_action: Optional[str] = Field(None, description="Action to take if a rollback fails: 'continue', 'pause'.") 250 | monitor: Optional[str] = Field( 251 | None, 252 | description="Duration to monitor each task for failures after it is created (e.g., '1s', '1m30s').", 253 | ) 254 | max_failure_ratio: Optional[Union[float, str]] = Field(None, description="Failure rate to tolerate during a rollback.") 255 | order: Optional[Order] = Field( 256 | None, 257 | description="Order of operations during rollbacks: 'stop-first' (default) or 'start-first'.", 258 | ) 259 | 260 | 261 | class UpdateConfig(BaseModel): 262 | model_config = ConfigDict( 263 | extra="forbid", 264 | ) 265 | parallelism: Optional[Union[int, str]] = Field(None, description="The number of containers to update at a time.") 266 | delay: Optional[str] = Field( 267 | None, 268 | description="The time to wait between updating a group of containers (e.g., '1s', '1m30s').", 269 | ) 270 | failure_action: Optional[str] = Field( 271 | None, 272 | description="Action to take if an update fails: 'continue', 'pause', 'rollback'.", 273 | ) 274 | monitor: Optional[str] = Field( 275 | None, 276 | description="Duration to monitor each updated task for failures after it is created (e.g., '1s', '1m30s').", 277 | ) 278 | max_failure_ratio: Optional[Union[float, str]] = Field(None, description="Failure rate to tolerate during an update (0 to 1).") 279 | order: Optional[Order] = Field( 280 | None, 281 | description="Order of operations during updates: 'stop-first' (default) or 'start-first'.", 282 | ) 283 | 284 | 285 | class Limits(BaseModel): 286 | model_config = ConfigDict( 287 | extra="forbid", 288 | ) 289 | cpus: Optional[Union[float, str]] = Field( 290 | None, 291 | description="Limit for how much of the available CPU resources, as number of cores, a container can use.", 292 | ) 293 | memory: Optional[str] = Field( 294 | None, 295 | description="Limit on the amount of memory a container can allocate (e.g., '1g', '1024m').", 296 | ) 297 | pids: Optional[Union[int, str]] = Field(None, description="Maximum number of PIDs available to the container.") 298 | 299 | 300 | class RestartPolicy(BaseModel): 301 | model_config = ConfigDict( 302 | extra="forbid", 303 | ) 304 | condition: Optional[str] = Field( 305 | None, 306 | description="Condition for restarting the container: 'none', 'on-failure', 'any'.", 307 | ) 308 | delay: Optional[str] = Field(None, description="Delay between restart attempts (e.g., '1s', '1m30s').") 309 | max_attempts: Optional[Union[int, str]] = Field(None, description="Maximum number of restart attempts before giving up.") 310 | window: Optional[str] = Field( 311 | None, 312 | description="Time window used to evaluate the restart policy (e.g., '1s', '1m30s').", 313 | ) 314 | 315 | 316 | class Preference(BaseModel): 317 | model_config = ConfigDict( 318 | extra="forbid", 319 | ) 320 | spread: Optional[str] = Field( 321 | None, 322 | description="Spread tasks evenly across values of the specified node label.", 323 | ) 324 | 325 | 326 | class Placement(BaseModel): 327 | model_config = ConfigDict( 328 | extra="forbid", 329 | ) 330 | constraints: Optional[List[str]] = Field( 331 | None, 332 | description="Placement constraints for the service (e.g., 'node.role==manager').", 333 | ) 334 | preferences: Optional[List[Preference]] = Field(None, description="Placement preferences for the service.") 335 | max_replicas_per_node: Optional[Union[int, str]] = Field(None, description="Maximum number of replicas of the service.") 336 | 337 | 338 | class DiscreteResourceSpec(BaseModel): 339 | model_config = ConfigDict( 340 | extra="forbid", 341 | ) 342 | kind: Optional[str] = Field(None, description="Type of resource (e.g., 'GPU', 'FPGA', 'SSD').") 343 | value: Optional[Union[float, str]] = Field(None, description="Number of resources of this kind to reserve.") 344 | 345 | 346 | class GenericResource(BaseModel): 347 | model_config = ConfigDict( 348 | extra="forbid", 349 | ) 350 | discrete_resource_spec: Optional[DiscreteResourceSpec] = Field(None, description="Specification for discrete (countable) resources.") 351 | 352 | 353 | class GenericResources(RootModel[List[GenericResource]]): 354 | root: List[GenericResource] = Field( 355 | ..., 356 | description="User-defined resources for services, allowing services to reserve specialized hardware resources.", 357 | ) 358 | 359 | 360 | class Gpus1(Enum): 361 | all = "all" 362 | 363 | 364 | class ConfigItem(BaseModel): 365 | model_config = ConfigDict( 366 | extra="forbid", 367 | ) 368 | subnet: Optional[str] = Field(None, description="Subnet in CIDR format that represents a network segment.") 369 | ip_range: Optional[str] = Field(None, description="Range of IPs from which to allocate container IPs.") 370 | gateway: Optional[str] = Field(None, description="IPv4 or IPv6 gateway for the subnet.") 371 | aux_addresses: Optional[Dict[str, str]] = Field(None, description="Auxiliary IPv4 or IPv6 addresses used by Network driver.") 372 | 373 | 374 | class Ipam(BaseModel): 375 | model_config = ConfigDict( 376 | extra="forbid", 377 | ) 378 | driver: Optional[str] = Field(None, description="Custom IPAM driver, instead of the default.") 379 | config: Optional[List[ConfigItem]] = Field(None, description="List of IPAM configuration blocks.") 380 | options: Optional[Dict[str, str]] = Field(None, description="Driver-specific options for the IPAM driver.") 381 | 382 | 383 | class ExternalVolumeNetwork(BaseModel): 384 | model_config = ConfigDict( 385 | extra="forbid", 386 | ) 387 | name: Optional[str] = Field( 388 | None, 389 | description="Specifies the name of the external network. Deprecated: use the 'name' property instead.", 390 | ) 391 | 392 | 393 | class ExternalConfig(BaseModel): 394 | name: Optional[str] = Field(None, description="Specifies the name of the external secret.") 395 | 396 | 397 | class External3(BaseModel): 398 | name: Optional[str] = Field( 399 | None, 400 | description="Specifies the name of the external config. Deprecated: use the 'name' property instead.", 401 | ) 402 | 403 | 404 | class Model(BaseModel): 405 | model_config = ConfigDict( 406 | extra="forbid", 407 | ) 408 | name: Optional[str] = Field(None, description="Custom name for this model.") 409 | model: str = Field(..., description="Language Model to run.") 410 | context_size: Optional[int] = None 411 | runtime_flags: Optional[List[str]] = Field(None, description="Raw runtime flags to pass to the inference engine.") 412 | 413 | 414 | class Command(RootModel[Optional[Union[str, List[str]]]]): 415 | root: Optional[Union[str, List[str]]] = Field( 416 | ..., 417 | description="Command to run in the container, which can be specified as a string (shell form) or array (exec form).", 418 | ) 419 | 420 | 421 | class EnvFilePath(BaseModel): 422 | model_config = ConfigDict( 423 | extra="forbid", 424 | ) 425 | path: str = Field(..., description="Path to the environment file.") 426 | format: Optional[str] = Field( 427 | None, 428 | description="Format attribute lets you to use an alternative file formats for env_file. When not set, env_file is parsed according to Compose rules.", 429 | ) 430 | required: Optional[Union[bool, str]] = Field( 431 | True, 432 | description="Whether the file is required. If true and the file doesn't exist, an error will be raised.", 433 | ) 434 | 435 | 436 | class EnvFile(RootModel[Union[str, List[Union[str, EnvFilePath]]]]): 437 | root: Union[str, List[Union[str, EnvFilePath]]] 438 | 439 | 440 | class LabelFile(RootModel[Union[str, List[str]]]): 441 | root: Union[str, List[str]] 442 | 443 | 444 | class ListOfStrings(RootModel[List[str]]): 445 | root: List[str] = Field(..., description="A list of unique string values.") 446 | 447 | 448 | class ListOrDict1(RootModel[List[Any]]): 449 | root: List[Any] = Field(..., description="A list of unique string values.") 450 | 451 | 452 | class ListOrDict(RootModel[Union[Dict[str, Optional[Union[str, float, bool]]], ListOrDict1]]): 453 | root: Union[Dict[str, Optional[Union[str, float, bool]]], ListOrDict1] = Field( 454 | ..., 455 | description="Either a dictionary mapping keys to values, or a list of strings.", 456 | ) 457 | 458 | 459 | class ExtraHosts1(RootModel[List[Any]]): 460 | root: List[Any] = Field(..., description="List of IP addresses for the hostname.") 461 | 462 | 463 | class ExtraHosts2(RootModel[List[Any]]): 464 | root: List[Any] = Field(..., description="List of host:IP mappings in the format 'hostname:IP'.") 465 | 466 | 467 | class ExtraHosts(RootModel[Union[Dict[str, Union[str, ExtraHosts1]], ExtraHosts2]]): 468 | root: Union[Dict[str, Union[str, ExtraHosts1]], ExtraHosts2] = Field( 469 | ..., 470 | description="Additional hostnames to be defined in the container's /etc/hosts file.", 471 | ) 472 | 473 | 474 | class BlkioLimit(BaseModel): 475 | model_config = ConfigDict( 476 | extra="forbid", 477 | ) 478 | path: Optional[str] = Field(None, description="Path to the device (e.g., '/dev/sda').") 479 | rate: Optional[Union[int, str]] = Field(None, description="Rate limit in bytes per second or IO operations per second.") 480 | 481 | 482 | class BlkioWeight(BaseModel): 483 | model_config = ConfigDict( 484 | extra="forbid", 485 | ) 486 | path: Optional[str] = Field(None, description="Path to the device (e.g., '/dev/sda').") 487 | weight: Optional[Union[int, str]] = Field(None, description="Relative weight for the device, between 10 and 1000.") 488 | 489 | 490 | class ServiceConfigOrSecret1(BaseModel): 491 | model_config = ConfigDict( 492 | extra="forbid", 493 | ) 494 | source: Optional[str] = Field( 495 | None, 496 | description="Name of the config or secret as defined in the top-level configs or secrets section.", 497 | ) 498 | target: Optional[str] = Field( 499 | None, 500 | description="Path in the container where the config or secret will be mounted. Defaults to / for configs and /run/secrets/ for secrets.", 501 | ) 502 | uid: Optional[str] = Field(None, description="UID of the file in the container. Default is 0 (root).") 503 | gid: Optional[str] = Field(None, description="GID of the file in the container. Default is 0 (root).") 504 | mode: Optional[Union[float, str]] = Field( 505 | None, 506 | description="File permission mode inside the container, in octal. Default is 0444 for configs and 0400 for secrets.", 507 | ) 508 | 509 | 510 | class ServiceConfigOrSecret(RootModel[List[Union[str, ServiceConfigOrSecret1]]]): 511 | root: List[Union[str, ServiceConfigOrSecret1]] = Field( 512 | ..., 513 | description="Configuration for service configs or secrets, defining how they are mounted in the container.", 514 | ) 515 | 516 | 517 | class Ulimits1(BaseModel): 518 | model_config = ConfigDict( 519 | extra="forbid", 520 | ) 521 | hard: Union[int, str] = Field( 522 | ..., 523 | description="Hard limit for the ulimit type. This is the maximum allowed value.", 524 | ) 525 | soft: Union[int, str] = Field( 526 | ..., 527 | description="Soft limit for the ulimit type. This is the value that's actually enforced.", 528 | ) 529 | 530 | 531 | class Ulimits(RootModel[Dict[str, Union[Union[int, str], Ulimits1]]]): 532 | root: Dict[str, Union[Union[int, str], Ulimits1]] = Field( 533 | ..., 534 | description="Container ulimit options, controlling resource limits for processes inside the container.", 535 | ) 536 | 537 | 538 | class Build(BaseModel): 539 | model_config = ConfigDict( 540 | extra="forbid", 541 | ) 542 | context: Optional[str] = Field(None, description="Path to the build context. Can be a relative path or a URL.") 543 | dockerfile: Optional[str] = Field(None, description="Name of the Dockerfile to use for building the image.") 544 | dockerfile_inline: Optional[str] = Field( 545 | None, 546 | description="Inline Dockerfile content to use instead of a Dockerfile from the build context.", 547 | ) 548 | entitlements: Optional[List[str]] = Field( 549 | None, 550 | description="List of extra privileged entitlements to grant to the build process.", 551 | ) 552 | args: Optional[ListOrDict] = Field( 553 | None, 554 | description="Build-time variables, specified as a map or a list of KEY=VAL pairs.", 555 | ) 556 | ssh: Optional[ListOrDict] = Field( 557 | None, 558 | description="SSH agent socket or keys to expose to the build. Format is either a string or a list of 'default|[=|[,]]'.", 559 | ) 560 | labels: Optional[ListOrDict] = Field(None, description="Labels to apply to the built image.") 561 | cache_from: Optional[List[str]] = Field( 562 | None, 563 | description="List of sources the image builder should use for cache resolution", 564 | ) 565 | cache_to: Optional[List[str]] = Field(None, description="Cache destinations for the build cache.") 566 | no_cache: Optional[Union[bool, str]] = Field(None, description="Do not use cache when building the image.") 567 | additional_contexts: Optional[ListOrDict] = Field( 568 | None, 569 | description="Additional build contexts to use, specified as a map of name to context path or URL.", 570 | ) 571 | network: Optional[str] = Field( 572 | None, 573 | description="Network mode to use for the build. Options include 'default', 'none', 'host', or a network name.", 574 | ) 575 | pull: Optional[Union[bool, str]] = Field(None, description="Always attempt to pull a newer version of the image.") 576 | target: Optional[str] = Field(None, description="Build stage to target in a multi-stage Dockerfile.") 577 | shm_size: Optional[Union[int, str]] = Field( 578 | None, 579 | description="Size of /dev/shm for the build container. A string value can use suffix like '2g' for 2 gigabytes.", 580 | ) 581 | extra_hosts: Optional[ExtraHosts] = Field(None, description="Add hostname mappings for the build container.") 582 | isolation: Optional[str] = Field(None, description="Container isolation technology to use for the build process.") 583 | privileged: Optional[Union[bool, str]] = Field(None, description="Give extended privileges to the build container.") 584 | secrets: Optional[ServiceConfigOrSecret] = Field( 585 | None, 586 | description="Secrets to expose to the build. These are accessible at build-time.", 587 | ) 588 | tags: Optional[List[str]] = Field(None, description="Additional tags to apply to the built image.") 589 | ulimits: Optional[Ulimits] = Field(None, description="Override the default ulimits for the build container.") 590 | platforms: Optional[List[str]] = Field( 591 | None, 592 | description="Platforms to build for, e.g., 'linux/amd64', 'linux/arm64', or 'windows/amd64'.", 593 | ) 594 | 595 | 596 | class BlkioConfig(BaseModel): 597 | model_config = ConfigDict( 598 | extra="forbid", 599 | ) 600 | device_read_bps: Optional[List[BlkioLimit]] = Field(None, description="Limit read rate (bytes per second) from a device.") 601 | device_read_iops: Optional[List[BlkioLimit]] = Field(None, description="Limit read rate (IO per second) from a device.") 602 | device_write_bps: Optional[List[BlkioLimit]] = Field(None, description="Limit write rate (bytes per second) to a device.") 603 | device_write_iops: Optional[List[BlkioLimit]] = Field(None, description="Limit write rate (IO per second) to a device.") 604 | weight: Optional[Union[int, str]] = Field( 605 | None, 606 | description="Block IO weight (relative weight) for the service, between 10 and 1000.", 607 | ) 608 | weight_device: Optional[List[BlkioWeight]] = Field(None, description="Block IO weight (relative weight) for specific devices.") 609 | 610 | 611 | class Networks(BaseModel): 612 | model_config = ConfigDict( 613 | extra="forbid", 614 | ) 615 | aliases: Optional[ListOfStrings] = Field(None, description="Alternative hostnames for this service on the network.") 616 | interface_name: Optional[str] = Field(None, description="Interface network name used to connect to network") 617 | ipv4_address: Optional[str] = Field( 618 | None, 619 | description="Specify a static IPv4 address for this service on this network.", 620 | ) 621 | ipv6_address: Optional[str] = Field( 622 | None, 623 | description="Specify a static IPv6 address for this service on this network.", 624 | ) 625 | link_local_ips: Optional[ListOfStrings] = Field(None, description="List of link-local IPs.") 626 | mac_address: Optional[str] = Field(None, description="Specify a MAC address for this service on this network.") 627 | driver_opts: Optional[Dict[str, Union[str, float]]] = Field(None, description="Driver options for this network.") 628 | priority: Optional[float] = Field(None, description="Specify the priority for the network connection.") 629 | gw_priority: Optional[float] = Field(None, description="Specify the gateway priority for the network connection.") 630 | 631 | 632 | class AdditionalVolumeOption(BaseModel): 633 | model_config = ConfigDict( 634 | extra="forbid", 635 | ) 636 | labels: Optional[ListOrDict] = Field(None, description="Labels to apply to the volume.") 637 | nocopy: Optional[Union[bool, str]] = Field( 638 | None, 639 | description="Flag to disable copying of data from a container when a volume is created.", 640 | ) 641 | subpath: Optional[str] = Field(None, description="Path within the volume to mount instead of the volume root.") 642 | 643 | 644 | class Volumes(BaseModel): 645 | model_config = ConfigDict( 646 | extra="forbid", 647 | ) 648 | type: Type = Field( 649 | ..., 650 | description="The mount type: bind for mounting host directories, volume for named volumes, tmpfs for temporary filesystems, cluster for cluster volumes, npipe for named pipes, or image for mounting from an image.", 651 | ) 652 | source: Optional[str] = Field( 653 | None, 654 | description="The source of the mount, a path on the host for a bind mount, a docker image reference for an image mount, or the name of a volume defined in the top-level volumes key. Not applicable for a tmpfs mount.", 655 | ) 656 | target: Optional[str] = Field(None, description="The path in the container where the volume is mounted.") 657 | read_only: Optional[Union[bool, str]] = Field(None, description="Flag to set the volume as read-only.") 658 | consistency: Optional[str] = Field( 659 | None, 660 | description="The consistency requirements for the mount. Available values are platform specific.", 661 | ) 662 | bind: Optional[Bind] = Field(None, description="Configuration specific to bind mounts.") 663 | volume: Optional[AdditionalVolumeOption] = Field(None, description="Configuration specific to volume mounts.") 664 | tmpfs: Optional[Tmpfs] = Field(None, description="Configuration specific to tmpfs mounts.") 665 | image: Optional[Image] = Field(None, description="Configuration specific to image mounts.") 666 | 667 | 668 | class Device(BaseModel): 669 | model_config = ConfigDict( 670 | extra="forbid", 671 | ) 672 | capabilities: ListOfStrings = Field( 673 | ..., 674 | description="List of capabilities the device needs to have (e.g., 'gpu', 'compute', 'utility').", 675 | ) 676 | count: Optional[Union[str, int]] = Field(None, description="Number of devices of this type to reserve.") 677 | device_ids: Optional[ListOfStrings] = Field(None, description="List of specific device IDs to reserve.") 678 | driver: Optional[str] = Field(None, description="Device driver to use (e.g., 'nvidia').") 679 | options: Optional[ListOrDict] = Field(None, description="Driver-specific options for the device.") 680 | 681 | 682 | class DevicesModel(RootModel[List[Device]]): 683 | root: List[Device] = Field( 684 | ..., 685 | description="Device reservations for containers, allowing services to access specific hardware devices.", 686 | ) 687 | 688 | 689 | class Gpu(BaseModel): 690 | capabilities: Optional[ListOfStrings] = Field( 691 | None, 692 | description="List of capabilities the GPU needs to have (e.g., 'compute', 'utility').", 693 | ) 694 | count: Optional[Union[str, int]] = Field(None, description="Number of GPUs to use.") 695 | device_ids: Optional[ListOfStrings] = Field(None, description="List of specific GPU device IDs to use.") 696 | driver: Optional[str] = Field(None, description="GPU driver to use (e.g., 'nvidia').") 697 | options: Optional[ListOrDict] = Field(None, description="Driver-specific options for the GPU.") 698 | 699 | 700 | class Gpus(RootModel[Union[Gpus1, List[Gpu]]]): 701 | root: Union[Gpus1, List[Gpu]] 702 | 703 | 704 | class Network(BaseModel): 705 | model_config = ConfigDict( 706 | extra="forbid", 707 | ) 708 | name: Optional[str] = Field(None, description="Custom name for this network.") 709 | driver: Optional[str] = Field( 710 | None, 711 | description="Specify which driver should be used for this network. Default is 'bridge'.", 712 | ) 713 | driver_opts: Optional[Dict[str, Union[str, float]]] = Field(None, description="Specify driver-specific options defined as key/value pairs.") 714 | ipam: Optional[Ipam] = Field(None, description="Custom IP Address Management configuration for this network.") 715 | external: Optional[Union[bool, ExternalVolumeNetwork]] = Field( 716 | None, 717 | description="Specifies that this network already exists and was created outside of Compose.", 718 | ) 719 | internal: Optional[Union[bool, str]] = Field(None, description="Create an externally isolated network.") 720 | enable_ipv4: Optional[Union[bool, str]] = Field(None, description="Enable IPv4 networking.") 721 | enable_ipv6: Optional[Union[bool, str]] = Field(None, description="Enable IPv6 networking.") 722 | attachable: Optional[Union[bool, str]] = Field(None, description="If true, standalone containers can attach to this network.") 723 | labels: Optional[ListOrDict] = Field(None, description="Add metadata to the network using labels.") 724 | 725 | 726 | class Volume(BaseModel): 727 | model_config = ConfigDict( 728 | extra="forbid", 729 | ) 730 | name: Optional[str] = Field(None, description="Custom name for this volume.") 731 | driver: Optional[str] = Field(None, description="Specify which volume driver should be used for this volume.") 732 | driver_opts: Optional[Dict[str, Union[str, float]]] = Field(None, description="Specify driver-specific options.") 733 | external: Optional[bool] = Field( 734 | None, 735 | description="Specifies that this volume already exists and was created outside of Compose.", 736 | ) 737 | labels: Optional[ListOrDict] = Field(None, description="Add metadata to the volume using labels.") 738 | 739 | 740 | class Secret(BaseModel): 741 | model_config = ConfigDict( 742 | extra="forbid", 743 | ) 744 | name: Optional[str] = Field(None, description="Custom name for this secret.") 745 | environment: Optional[str] = Field( 746 | None, 747 | description="Name of an environment variable from which to get the secret value.", 748 | ) 749 | file: Optional[str] = Field(None, description="Path to a file containing the secret value.") 750 | external: Optional[Union[bool, ExternalConfig]] = Field( 751 | None, 752 | description="Specifies that this secret already exists and was created outside of Compose.", 753 | ) 754 | labels: Optional[ListOrDict] = Field(None, description="Add metadata to the secret using labels.") 755 | driver: Optional[str] = Field(None, description="Specify which secret driver should be used for this secret.") 756 | driver_opts: Optional[Dict[str, Union[str, float]]] = Field(None, description="Specify driver-specific options.") 757 | template_driver: Optional[str] = Field(None, description="Driver to use for templating the secret's value.") 758 | 759 | 760 | class Config(BaseModel): 761 | model_config = ConfigDict( 762 | extra="forbid", 763 | ) 764 | name: Optional[str] = Field(None, description="Custom name for this config.") 765 | content: Optional[str] = Field(None, description="Inline content of the config.") 766 | environment: Optional[str] = Field( 767 | None, 768 | description="Name of an environment variable from which to get the config value.", 769 | ) 770 | file: Optional[str] = Field(None, description="Path to a file containing the config value.") 771 | external: Optional[External3] = Field( 772 | None, 773 | description="Specifies that this config already exists and was created outside of Compose.", 774 | ) 775 | labels: Optional[ListOrDict] = Field(None, description="Add metadata to the config using labels.") 776 | template_driver: Optional[str] = Field(None, description="Driver to use for templating the config's value.") 777 | 778 | 779 | class ServiceHook(BaseModel): 780 | model_config = ConfigDict( 781 | extra="forbid", 782 | ) 783 | command: Command = Field(..., description="Command to execute as part of the hook.") 784 | user: Optional[str] = Field(None, description="User to run the command as.") 785 | privileged: Optional[Union[bool, str]] = Field(None, description="Whether to run the command with extended privileges.") 786 | working_dir: Optional[str] = Field(None, description="Working directory for the command.") 787 | environment: Optional[ListOrDict] = Field(None, description="Environment variables for the command.") 788 | 789 | 790 | class StringOrList(RootModel[Union[str, ListOfStrings]]): 791 | root: Union[str, ListOfStrings] = Field(..., description="Either a single string or a list of strings.") 792 | 793 | 794 | class WatchItem(BaseModel): 795 | model_config = ConfigDict( 796 | extra="forbid", 797 | ) 798 | ignore: Optional[StringOrList] = Field(None, description="Patterns to exclude from watching.") 799 | include: Optional[StringOrList] = Field(None, description="Patterns to include in watching.") 800 | path: str = Field(..., description="Path to watch for changes.") 801 | action: Action = Field( 802 | ..., 803 | description="Action to take when a change is detected: rebuild the container, sync files, restart the container, sync and restart, or sync and execute a command.", 804 | ) 805 | target: Optional[str] = Field(None, description="Target path in the container for sync operations.") 806 | exec: Optional[ServiceHook] = Field( 807 | None, 808 | description="Command to execute when a change is detected and action is sync+exec.", 809 | ) 810 | 811 | 812 | class Development(BaseModel): 813 | model_config = ConfigDict( 814 | extra="forbid", 815 | ) 816 | watch: Optional[List[WatchItem]] = Field( 817 | None, 818 | description="Configure watch mode for the service, which monitors file changes and performs actions in response.", 819 | ) 820 | 821 | 822 | class Reservations(BaseModel): 823 | model_config = ConfigDict( 824 | extra="forbid", 825 | ) 826 | cpus: Optional[Union[float, str]] = Field( 827 | None, 828 | description="Reservation for how much of the available CPU resources, as number of cores, a container can use.", 829 | ) 830 | memory: Optional[str] = Field( 831 | None, 832 | description="Reservation on the amount of memory a container can allocate (e.g., '1g', '1024m').", 833 | ) 834 | generic_resources: Optional[GenericResources] = Field(None, description="User-defined resources to reserve.") 835 | devices: Optional[DevicesModel] = Field(None, description="Device reservations for the container.") 836 | 837 | 838 | class Resources(BaseModel): 839 | model_config = ConfigDict( 840 | extra="forbid", 841 | ) 842 | limits: Optional[Limits] = Field(None, description="Resource limits for the service containers.") 843 | reservations: Optional[Reservations] = Field(None, description="Resource reservations for the service containers.") 844 | 845 | 846 | class Deployment(BaseModel): 847 | model_config = ConfigDict( 848 | extra="forbid", 849 | ) 850 | mode: Optional[str] = Field( 851 | None, 852 | description="Deployment mode for the service: 'replicated' (default) or 'global'.", 853 | ) 854 | endpoint_mode: Optional[str] = Field(None, description="Endpoint mode for the service: 'vip' (default) or 'dnsrr'.") 855 | replicas: Optional[Union[int, str]] = Field(None, description="Number of replicas of the service container to run.") 856 | labels: Optional[ListOrDict] = Field(None, description="Labels to apply to the service.") 857 | rollback_config: Optional[RollbackConfig] = Field(None, description="Configuration for rolling back a service update.") 858 | update_config: Optional[UpdateConfig] = Field(None, description="Configuration for updating a service.") 859 | resources: Optional[Resources] = Field(None, description="Resource constraints and reservations for the service.") 860 | restart_policy: Optional[RestartPolicy] = Field(None, description="Restart policy for the service containers.") 861 | placement: Optional[Placement] = Field( 862 | None, 863 | description="Constraints and preferences for the platform to select a physical node to run service containers", 864 | ) 865 | 866 | 867 | class Include1(BaseModel): 868 | model_config = ConfigDict( 869 | extra="forbid", 870 | ) 871 | path: Optional[StringOrList] = Field( 872 | None, 873 | description="Path to the Compose application or sub-project files to include.", 874 | ) 875 | env_file: Optional[StringOrList] = Field( 876 | None, 877 | description="Path to the environment files to use to define default values when interpolating variables in the Compose files being parsed.", 878 | ) 879 | project_directory: Optional[str] = Field(None, description="Path to resolve relative paths set in the Compose file") 880 | 881 | 882 | class Include(RootModel[Union[str, Include1]]): 883 | root: Union[str, Include1] = Field(..., description="Compose application or sub-projects to be included.") 884 | 885 | 886 | class Service(BaseModel): 887 | model_config = ConfigDict( 888 | extra="forbid", 889 | ) 890 | develop: Optional[Development] = None 891 | deploy: Optional[Deployment] = None 892 | annotations: Optional[ListOrDict] = None 893 | attach: Optional[Union[bool, str]] = None 894 | build: Optional[Union[str, Build]] = Field(None, description="Configuration options for building the service's image.") 895 | blkio_config: Optional[BlkioConfig] = Field(None, description="Block IO configuration for the service.") 896 | cap_add: Optional[List[str]] = Field( 897 | None, 898 | description="Add Linux capabilities. For example, 'CAP_SYS_ADMIN', 'SYS_ADMIN', or 'NET_ADMIN'.", 899 | ) 900 | cap_drop: Optional[List[str]] = Field( 901 | None, 902 | description="Drop Linux capabilities. For example, 'CAP_SYS_ADMIN', 'SYS_ADMIN', or 'NET_ADMIN'.", 903 | ) 904 | cgroup: Optional[Cgroup] = Field( 905 | None, 906 | description="Specify the cgroup namespace to join. Use 'host' to use the host's cgroup namespace, or 'private' to use a private cgroup namespace.", 907 | ) 908 | cgroup_parent: Optional[str] = Field(None, description="Specify an optional parent cgroup for the container.") 909 | command: Optional[Command] = Field( 910 | None, 911 | description="Override the default command declared by the container image, for example 'CMD' in Dockerfile.", 912 | ) 913 | configs: Optional[ServiceConfigOrSecret] = Field(None, description="Grant access to Configs on a per-service basis.") 914 | container_name: Optional[str] = Field( 915 | None, 916 | description="Specify a custom container name, rather than a generated default name.", 917 | pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+", 918 | ) 919 | cpu_count: Optional[Union[str, CpuCount]] = Field(None, description="Number of usable CPUs.") 920 | cpu_percent: Optional[Union[str, CpuPercent]] = Field(None, description="Percentage of CPU resources to use.") 921 | cpu_shares: Optional[Union[float, str]] = Field(None, description="CPU shares (relative weight) for the container.") 922 | cpu_quota: Optional[Union[float, str]] = Field(None, description="Limit the CPU CFS (Completely Fair Scheduler) quota.") 923 | cpu_period: Optional[Union[float, str]] = Field(None, description="Limit the CPU CFS (Completely Fair Scheduler) period.") 924 | cpu_rt_period: Optional[Union[float, str]] = Field( 925 | None, 926 | description="Limit the CPU real-time period in microseconds or a duration.", 927 | ) 928 | cpu_rt_runtime: Optional[Union[float, str]] = Field( 929 | None, 930 | description="Limit the CPU real-time runtime in microseconds or a duration.", 931 | ) 932 | cpus: Optional[Union[float, str]] = Field( 933 | None, 934 | description="Number of CPUs to use. A floating-point value is supported to request partial CPUs.", 935 | ) 936 | cpuset: Optional[str] = Field(None, description="CPUs in which to allow execution (0-3, 0,1).") 937 | credential_spec: Optional[CredentialSpec] = Field(None, description="Configure the credential spec for managed service account.") 938 | depends_on: Optional[Union[ListOfStrings, Dict[str, DependsOn]]] = Field( 939 | None, 940 | description="Express dependency between services. Service dependencies cause services to be started in dependency order. The dependent service will wait for the dependency to be ready before starting.", 941 | ) 942 | device_cgroup_rules: Optional[ListOfStrings] = Field(None, description="Add rules to the cgroup allowed devices list.") 943 | devices: Optional[List[Union[str, Devices]]] = Field(None, description="List of device mappings for the container.") 944 | dns: Optional[StringOrList] = Field(None, description="Custom DNS servers to set for the service container.") 945 | dns_opt: Optional[List[str]] = Field( 946 | None, 947 | description="Custom DNS options to be passed to the container's DNS resolver.", 948 | ) 949 | dns_search: Optional[StringOrList] = Field(None, description="Custom DNS search domains to set on the service container.") 950 | domainname: Optional[str] = Field(None, description="Custom domain name to use for the service container.") 951 | entrypoint: Optional[Command] = Field( 952 | None, 953 | description="Override the default entrypoint declared by the container image, for example 'ENTRYPOINT' in Dockerfile.", 954 | ) 955 | env_file: Optional[EnvFile] = Field( 956 | None, 957 | description="Add environment variables from a file or multiple files. Can be a single file path or a list of file paths.", 958 | ) 959 | label_file: Optional[LabelFile] = Field( 960 | None, 961 | description="Add metadata to containers using files containing Docker labels.", 962 | ) 963 | environment: Optional[ListOrDict] = Field( 964 | None, 965 | description="Add environment variables. You can use either an array or a list of KEY=VAL pairs.", 966 | ) 967 | expose: Optional[List[Union[str, int]]] = Field( 968 | None, 969 | description="Expose ports without publishing them to the host machine - they'll only be accessible to linked services.", 970 | ) 971 | extends: Optional[Union[str, Extends]] = Field(None, description="Extend another service, in the current file or another file.") 972 | provider: Optional[Provider] = Field( 973 | None, 974 | description="Specify a service which will not be manage by Compose directly, and delegate its management to an external provider.", 975 | ) 976 | external_links: Optional[List[str]] = Field( 977 | None, 978 | description="Link to services started outside this Compose application. Specify services as :.", 979 | ) 980 | extra_hosts: Optional[ExtraHosts] = Field( 981 | None, 982 | description="Add hostname mappings to the container network interface configuration.", 983 | ) 984 | gpus: Optional[Gpus] = Field( 985 | None, 986 | description="Define GPU devices to use. Can be set to 'all' to use all GPUs, or a list of specific GPU devices.", 987 | ) 988 | group_add: Optional[List[Union[str, float]]] = Field( 989 | None, 990 | description="Add additional groups which user inside the container should be member of.", 991 | ) 992 | healthcheck: Optional[Healthcheck] = Field( 993 | None, 994 | description="Configure a health check for the container to monitor its health status.", 995 | ) 996 | hostname: Optional[str] = Field(None, description="Define a custom hostname for the service container.") 997 | image: Optional[str] = Field( 998 | None, 999 | description="Specify the image to start the container from. Can be a repository/tag, a digest, or a local image ID.", 1000 | ) 1001 | init: Optional[Union[bool, str]] = Field( 1002 | None, 1003 | description="Run as an init process inside the container that forwards signals and reaps processes.", 1004 | ) 1005 | ipc: Optional[str] = Field( 1006 | None, 1007 | description="IPC sharing mode for the service container. Use 'host' to share the host's IPC namespace, 'service:[service_name]' to share with another service, or 'shareable' to allow other services to share this service's IPC namespace.", 1008 | ) 1009 | isolation: Optional[str] = Field( 1010 | None, 1011 | description="Container isolation technology to use. Supported values are platform-specific.", 1012 | ) 1013 | labels: Optional[ListOrDict] = Field( 1014 | None, 1015 | description="Add metadata to containers using Docker labels. You can use either an array or a list.", 1016 | ) 1017 | links: Optional[List[str]] = Field( 1018 | None, 1019 | description="Link to containers in another service. Either specify both the service name and a link alias (SERVICE:ALIAS), or just the service name.", 1020 | ) 1021 | logging: Optional[Logging] = Field(None, description="Logging configuration for the service.") 1022 | mac_address: Optional[str] = Field(None, description="Container MAC address to set.") 1023 | mem_limit: Optional[Union[float, str]] = Field( 1024 | None, 1025 | description="Memory limit for the container. A string value can use suffix like '2g' for 2 gigabytes.", 1026 | ) 1027 | mem_reservation: Optional[Union[str, int]] = Field(None, description="Memory reservation for the container.") 1028 | mem_swappiness: Optional[Union[int, str]] = Field(None, description="Container memory swappiness as percentage (0 to 100).") 1029 | memswap_limit: Optional[Union[float, str]] = Field( 1030 | None, 1031 | description="Amount of memory the container is allowed to swap to disk. Set to -1 to enable unlimited swap.", 1032 | ) 1033 | network_mode: Optional[str] = Field( 1034 | None, 1035 | description="Network mode. Values can be 'bridge', 'host', 'none', 'service:[service name]', or 'container:[container name]'.", 1036 | ) 1037 | models: Optional[Union[ListOfStrings, Dict[str, Models]]] = Field( 1038 | None, 1039 | description="AI Models to use, referencing entries under the top-level models key.", 1040 | ) 1041 | networks: Optional[Union[ListOfStrings, Dict[str, Optional[Networks]]]] = Field( 1042 | None, 1043 | description="Networks to join, referencing entries under the top-level networks key. Can be a list of network names or a mapping of network name to network configuration.", 1044 | ) 1045 | oom_kill_disable: Optional[Union[bool, str]] = Field(None, description="Disable OOM Killer for the container.") 1046 | oom_score_adj: Optional[Union[str, OomScoreAdj]] = Field( 1047 | None, 1048 | description="Tune host's OOM preferences for the container (accepts -1000 to 1000).", 1049 | ) 1050 | pid: Optional[str] = Field(None, description="PID mode for container.") 1051 | pids_limit: Optional[Union[float, str]] = Field(None, description="Tune a container's PIDs limit. Set to -1 for unlimited PIDs.") 1052 | platform: Optional[str] = Field( 1053 | None, 1054 | description="Target platform to run on, e.g., 'linux/amd64', 'linux/arm64', or 'windows/amd64'.", 1055 | ) 1056 | ports: Optional[List[Union[float, str, Ports]]] = Field( 1057 | None, 1058 | description="Expose container ports. Short format ([HOST:]CONTAINER[/PROTOCOL]).", 1059 | ) 1060 | post_start: Optional[List[ServiceHook]] = Field( 1061 | None, 1062 | description="Commands to run after the container starts. If any command fails, the container stops.", 1063 | ) 1064 | pre_stop: Optional[List[ServiceHook]] = Field( 1065 | None, 1066 | description="Commands to run before the container stops. If any command fails, the container stop is aborted.", 1067 | ) 1068 | privileged: Optional[Union[bool, str]] = Field(None, description="Give extended privileges to the service container.") 1069 | profiles: Optional[ListOfStrings] = Field( 1070 | None, 1071 | description="List of profiles for this service. When profiles are specified, services are only started when the profile is activated.", 1072 | ) 1073 | pull_policy: Optional[str] = Field( 1074 | None, 1075 | description="Policy for pulling images. Options include: 'always', 'never', 'if_not_present', 'missing', 'build', or time-based refresh policies.", 1076 | pattern="always|never|build|if_not_present|missing|refresh|daily|weekly|every_([0-9]+[wdhms])+", 1077 | ) 1078 | pull_refresh_after: Optional[str] = Field( 1079 | None, 1080 | description="Time after which to refresh the image. Used with pull_policy=refresh.", 1081 | ) 1082 | read_only: Optional[Union[bool, str]] = Field(None, description="Mount the container's filesystem as read only.") 1083 | restart: Optional[str] = Field( 1084 | None, 1085 | description="Restart policy for the service container. Options include: 'no', 'always', 'on-failure', and 'unless-stopped'.", 1086 | ) 1087 | runtime: Optional[str] = Field(None, description="Runtime to use for this container, e.g., 'runc'.") 1088 | scale: Optional[Union[int, str]] = Field(None, description="Number of containers to deploy for this service.") 1089 | security_opt: Optional[List[str]] = Field(None, description="Override the default labeling scheme for each container.") 1090 | shm_size: Optional[Union[float, str]] = Field( 1091 | None, 1092 | description="Size of /dev/shm. A string value can use suffix like '2g' for 2 gigabytes.", 1093 | ) 1094 | secrets: Optional[ServiceConfigOrSecret] = Field(None, description="Grant access to Secrets on a per-service basis.") 1095 | sysctls: Optional[ListOrDict] = Field( 1096 | None, 1097 | description="Kernel parameters to set in the container. You can use either an array or a list.", 1098 | ) 1099 | stdin_open: Optional[Union[bool, str]] = Field(None, description="Keep STDIN open even if not attached.") 1100 | stop_grace_period: Optional[str] = Field( 1101 | None, 1102 | description="Time to wait for the container to stop gracefully before sending SIGKILL (e.g., '1s', '1m30s').", 1103 | ) 1104 | stop_signal: Optional[str] = Field(None, description="Signal to stop the container (e.g., 'SIGTERM', 'SIGINT').") 1105 | storage_opt: Optional[Dict[str, Any]] = Field(None, description="Storage driver options for the container.") 1106 | tmpfs: Optional[StringOrList] = Field( 1107 | None, 1108 | description="Mount a temporary filesystem (tmpfs) into the container. Can be a single value or a list.", 1109 | ) 1110 | tty: Optional[Union[bool, str]] = Field(None, description="Allocate a pseudo-TTY to service container.") 1111 | ulimits: Optional[Ulimits] = Field(None, description="Override the default ulimits for a container.") 1112 | use_api_socket: Optional[bool] = Field(None, description="Bind mount Docker API socket and required auth.") 1113 | user: Optional[str] = Field(None, description="Username or UID to run the container process as.") 1114 | uts: Optional[str] = Field( 1115 | None, 1116 | description="UTS namespace to use. 'host' shares the host's UTS namespace.", 1117 | ) 1118 | userns_mode: Optional[str] = Field( 1119 | None, 1120 | description="User namespace to use. 'host' shares the host's user namespace.", 1121 | ) 1122 | volumes: Optional[List[Union[str, Volumes]]] = Field( 1123 | None, 1124 | description="Mount host paths or named volumes accessible to the container. Short syntax (VOLUME:CONTAINER_PATH[:MODE])", 1125 | ) 1126 | volumes_from: Optional[List[str]] = Field( 1127 | None, 1128 | description="Mount volumes from another service or container. Optionally specify read-only access (ro) or read-write (rw).", 1129 | ) 1130 | working_dir: Optional[str] = Field( 1131 | None, 1132 | description="The working directory in which the entrypoint or command will be run", 1133 | ) 1134 | 1135 | 1136 | class ComposeSpecification(BaseModel): 1137 | model_config = ConfigDict( 1138 | extra="forbid", 1139 | ) 1140 | version: Optional[str] = Field( 1141 | None, 1142 | description="declared for backward compatibility, ignored. Please remove it.", 1143 | ) 1144 | name: Optional[str] = Field( 1145 | None, 1146 | description="define the Compose project name, until user defines one explicitly.", 1147 | ) 1148 | include: Optional[List[Include]] = Field(None, description="compose sub-projects to be included.") 1149 | services: Optional[Dict[str, Service]] = Field(None, description="The services that will be used by your application.") 1150 | models: Optional[Dict[str, Model]] = Field(None, description="Language models that will be used by your application.") 1151 | networks: Optional[Dict[str, Optional[Network]]] = Field(None, description="Networks that are shared among multiple services.") 1152 | volumes: Optional[Dict[str, Optional[Volume]]] = Field(None, description="Named volumes that are shared among multiple services.") 1153 | secrets: Optional[Dict[str, Secret]] = Field(None, description="Secrets that are shared among multiple services.") 1154 | configs: Optional[Dict[str, Config]] = Field(None, description="Configurations that are shared among multiple services.") 1155 | --------------------------------------------------------------------------------