diff --git a/docs/commands/chart.md b/docs/commands/chart.md index c160511..91c43b7 100644 --- a/docs/commands/chart.md +++ b/docs/commands/chart.md @@ -10,30 +10,61 @@ stack chart [OPTIONS] ## Description -[Placeholder: Add detailed description of how the chart command generates visual representations of stack architecture using Mermaid diagrams] +Renders the structure of a stack: its services, which images they run and which +are built locally, how HTTP requests are routed to them, their volumes and their +dependencies on one another. Super stacks are rendered with their component +stacks nested inside. + +Two renderers are available, selected with `--format`. Both read the same model, +so the `--show-*` options apply to either. ## Options | Option | Type | Description | Default | |--------|------|-------------|---------| | `--stack` | TEXT | Name or path of the stack | - | +| `--format` | CHOICE | `mermaid` diagram or plain `text` tree | `mermaid` | | `--show-ports/--no-show-ports` | FLAG | Show port mappings in the chart | False | | `--show-http-targets/--no-show-http-targets` | FLAG | Show HTTP proxy targets in the chart | True | | `--show-volumes/--no-show-volumes` | FLAG | Show volume mounts in the chart | True | ## Output Format -The command generates a Mermaid diagram that can be rendered using: +### `--format mermaid` (default) + +Generates a Mermaid diagram that can be rendered using: - Mermaid CLI tools - Markdown renderers with Mermaid support - Online Mermaid editors +### `--format text` + +Prints a tree, for a quick look at a stack in a terminal without rendering +anything: + +``` +todo +├── backend bozemanpass/todo-backend:stack (build ./backend) +│ http :5000 -> /api/todos +│ needs db +├── frontend bozemanpass/todo-frontend:stack (build ./frontend) +│ http :3000 -> / +└── db postgres:14 + volume db-data -> /var/lib/postgresql/data +``` + +Ports that are already shown as an HTTP route are omitted under `--show-ports`, +so that the same mapping is not reported twice. + ## Examples ```bash # Generate a basic chart for a stack stack chart --stack my-stack +# Summarise a stack as text, without rendering a diagram +stack chart --stack my-stack --format text + # Generate a chart showing all details stack chart --stack my-stack --show-ports diff --git a/src/stack/chart/chart.py b/src/stack/chart/chart.py index 15ab16c..3a43f7c 100644 --- a/src/stack/chart/chart.py +++ b/src/stack/chart/chart.py @@ -33,16 +33,98 @@ } +def _image_summary(svc_config): + """One-line description of what a service runs, and whether it is built here.""" + image = svc_config.get("image") or "?" + build = svc_config.get("build") + if isinstance(build, dict): + build = build.get("context") + return f"{image} (build {build})" if build else image + + +def _depends_on(svc_config): + depends = svc_config.get("depends_on") or [] + # Compose allows either a plain list or a mapping of name -> condition. + return list(depends.keys()) if isinstance(depends, dict) else list(depends) + + +def _render_stack_text(stack, show_http_targets, show_ports, show_volumes, indent="", lines=None): + """Render the stack as an indented tree, mirroring what the mermaid chart shows.""" + if lines is None: + lines = [] + lines.append(f"{indent}{stack.name}") + + if stack.is_super_stack(): + children = stack.get_required_stacks_paths() + for i, child in enumerate(children): + last_child = i == len(children) - 1 + branch = "└── " if last_child else "├── " + child_indent = indent + (" " if last_child else "│ ") + child_stack = resolve_stack(child) + # Render the child at its own indent, then replace its root line so the child + # stack hangs off this stack's branch instead of being indented under it. + child_lines = _render_stack_text(child_stack, show_http_targets, show_ports, show_volumes, child_indent, []) + child_lines[0] = f"{indent}{branch}{child_stack.name}" + lines.extend(child_lines) + return lines + + services = stack.get_services() + http_targets = stack.get_http_proxy_targets() if show_http_targets else [] + ports = stack.get_ports() if show_ports else {} + volumes = stack.get_volumes() if show_volumes else {} + + name_width = max((len(s) for s in services), default=0) + service_names = list(services) + for i, svc in enumerate(service_names): + last = i == len(service_names) - 1 + branch = "└── " if last else "├── " + # Detail lines hang under the service, so they need the continuation bar. + detail_indent = indent + (" " if last else "│ ") + lines.append(f"{indent}{branch}{svc.ljust(name_width)} {_image_summary(services[svc])}") + + for ht in [t for t in http_targets if t["service"] == svc]: + lines.append(f"{detail_indent}http :{ht['port']} -> {ht.get('path', '/')}") + + shown_http_ports = {str(t["port"]) for t in http_targets if t["service"] == svc} + for port in ports.get(svc, []): + # Skip ports already shown as an http route to avoid saying the same thing twice. + if str(port).split(":")[-1] in shown_http_ports: + continue + lines.append(f"{detail_indent}port {port}") + + for volume in volumes.get(svc, []): + volume_name, _, mount = str(volume).partition(":") + lines.append(f"{detail_indent}volume {volume_name}" + (f" -> {mount}" if mount else "")) + + for dep in _depends_on(services[svc]): + lines.append(f"{detail_indent}needs {dep}") + + return lines + + @click.command() @click.option("--stack", help="name or path of the stack", required=False) @click.option("--show-ports/--no-show-ports", default=False) @click.option("--show-http-targets/--no-show-http-targets", default=True) @click.option("--show-volumes/--no-show-volumes", default=True) +@click.option( + "--format", + "output_format", + type=click.Choice(["mermaid", "text"]), + default="mermaid", + help="render as a mermaid diagram, or as a plain text tree", +) @click.pass_context -def command(ctx, stack, show_ports, show_http_targets, show_volumes): +def command(ctx, stack, show_ports, show_http_targets, show_volumes, output_format): """generate a mermaid graph of the stack""" parent_stack = resolve_stack(stack) + + if output_format == "text": + for line in _render_stack_text(parent_stack, show_http_targets, show_ports, show_volumes): + output_main(line) + return + chart = Chart(direction=ChartDir.RL) for cls, style in _theme.items(): diff --git a/tests/unit/test_chart_text.py b/tests/unit/test_chart_text.py new file mode 100644 index 0000000..4f225c9 --- /dev/null +++ b/tests/unit/test_chart_text.py @@ -0,0 +1,170 @@ +# Copyright © 2026 Bozeman Pass, Inc. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Tests for the plain text renderer behind `stack chart --format text`.""" + +from stack.chart import chart +from stack.chart.chart import _depends_on, _image_summary, _render_stack_text + + +class FakeStack: + """Stands in for a Stack; the text renderer only reads these accessors.""" + + def __init__(self, name, services=None, http_targets=None, ports=None, volumes=None, required=None): + self.name = name + self._services = services or {} + self._http_targets = http_targets or [] + self._ports = ports or {} + self._volumes = volumes or {} + self._required = required or [] + + def is_super_stack(self): + return bool(self._required) + + def get_required_stacks_paths(self): + return list(self._required) + + def get_services(self): + return self._services + + def get_http_proxy_targets(self): + return self._http_targets + + def get_ports(self): + return self._ports + + def get_volumes(self): + return self._volumes + + +def _todo_stack(): + return FakeStack( + "todo", + services={ + "backend": {"image": "org/backend:stack", "build": "./backend", "depends_on": {"db": {"condition": "healthy"}}}, + "frontend": {"image": "org/frontend:stack", "build": "./frontend"}, + "db": {"image": "postgres:14"}, + }, + http_targets=[ + {"service": "backend", "port": "5000", "path": "/api/todos"}, + {"service": "frontend", "port": "3000", "path": "/"}, + ], + ports={"backend": ["5000:5000"], "frontend": ["3000:3000"], "db": ["5432:5432"]}, + volumes={"db": ["db-data:/var/lib/postgresql/data"]}, + ) + + +def _render(stack, show_http_targets=True, show_ports=False, show_volumes=True): + return _render_stack_text(stack, show_http_targets, show_ports, show_volumes) + + +# --------------------------------------------------------------------------- +# _image_summary / _depends_on +# --------------------------------------------------------------------------- + + +def test_image_summary_notes_locally_built_images(): + assert _image_summary({"image": "org/app:stack", "build": "./app"}) == "org/app:stack (build ./app)" + + +def test_image_summary_plain_for_pulled_images(): + assert _image_summary({"image": "postgres:14"}) == "postgres:14" + + +def test_image_summary_accepts_build_as_a_mapping(): + # Compose allows `build:` to be a mapping with a context rather than a bare string. + assert _image_summary({"image": "org/app:stack", "build": {"context": "./app"}}) == "org/app:stack (build ./app)" + + +def test_image_summary_tolerates_a_missing_image(): + assert _image_summary({}) == "?" + + +def test_depends_on_handles_both_compose_forms(): + assert _depends_on({"depends_on": ["db"]}) == ["db"] + assert _depends_on({"depends_on": {"db": {"condition": "healthy"}}}) == ["db"] + assert _depends_on({}) == [] + + +# --------------------------------------------------------------------------- +# tree rendering +# --------------------------------------------------------------------------- + + +def test_renders_services_under_the_stack_name(): + lines = _render(_todo_stack()) + assert lines[0] == "todo" + assert any(line.startswith("├── backend") for line in lines) + # The final service uses the closing branch. + assert any(line.startswith("└── db") for line in lines) + + +def test_service_names_are_aligned(): + lines = _render(_todo_stack()) + service_lines = [line for line in lines if line.startswith(("├── ", "└── "))] + # Each service's image summary should begin at the same column. + offsets = {line.index("org/") if "org/" in line else line.index("postgres") for line in service_lines} + assert len(offsets) == 1 + + +def test_http_routes_are_shown_for_the_owning_service(): + lines = _render(_todo_stack()) + assert any("http :5000 -> /api/todos" in line for line in lines) + assert any("http :3000 -> /" in line for line in lines) + + +def test_dependencies_and_volumes_are_shown(): + lines = _render(_todo_stack()) + assert any("needs db" in line for line in lines) + assert any("volume db-data -> /var/lib/postgresql/data" in line for line in lines) + + +def test_ports_are_hidden_by_default(): + assert not any("port " in line for line in _render(_todo_stack())) + + +def test_show_ports_omits_ports_already_shown_as_http_routes(): + lines = _render(_todo_stack(), show_ports=True) + # db's port is not an http route, so it is worth showing... + assert any("port 5432:5432" in line for line in lines) + # ...but the http services' ports would just repeat the route lines. + assert not any("port 5000:5000" in line for line in lines) + assert not any("port 3000:3000" in line for line in lines) + + +def test_sections_can_be_suppressed(): + lines = _render(_todo_stack(), show_http_targets=False, show_volumes=False) + assert not any("http :" in line for line in lines) + assert not any("volume " in line for line in lines) + + +def test_super_stack_nests_its_children(monkeypatch): + child_a = FakeStack("web", services={"nginx": {"image": "nginx:1"}}) + child_b = FakeStack("api", services={"app": {"image": "org/api:stack"}}) + parent = FakeStack("platform", required=["path-a", "path-b"]) + + by_path = {"path-a": child_a, "path-b": child_b} + monkeypatch.setattr(chart, "resolve_stack", lambda path: by_path[path]) + + lines = _render(parent) + + assert lines[0] == "platform" + # Each child stack hangs off the parent, keeping its own name as the subtree root. + assert "├── web" in lines + assert "└── api" in lines + # A non-final child's services stay under the continuation bar. + assert any(line.startswith("│ └── nginx") for line in lines) + # The final child's services are no longer under a bar. + assert any(line.startswith(" └── app") for line in lines)