Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions docs/commands/chart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 83 additions & 1 deletion src/stack/chart/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
170 changes: 170 additions & 0 deletions tests/unit/test_chart_text.py
Original file line number Diff line number Diff line change
@@ -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 <http:#www.gnu.org/licenses/>.

"""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)