diff --git a/pyproject.toml b/pyproject.toml
index e7027fc..2635095 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ dependencies = [
"requests",
"expandvars",
"mergedeep",
+ "mermaid-builder",
"termcolor",
"flake8",
"black"
diff --git a/src/stack/chart/__init__.py b/src/stack/chart/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/stack/chart/chart.py b/src/stack/chart/chart.py
new file mode 100644
index 0000000..c009662
--- /dev/null
+++ b/src/stack/chart/chart.py
@@ -0,0 +1,129 @@
+# Copyright © 2025 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 .
+
+
+import click
+
+from mermaid_builder.flowchart import Chart, Node, NodeShape, Subgraph, ChartDir, ClassDef
+
+from stack.config.util import get_config_setting
+from stack.deploy.stack import resolve_stack
+from stack.log import output_main
+
+
+_theme = {
+ "super_stack": "stroke:#FFF176,fill:#FFFEEF,color:#6B5E13,stroke-width:2px,font-size:small;",
+ "stack": "stroke:#00C9A7,fill:#EDFDFB,color:#1A3A38,stroke-width:2px,font-size:small;",
+ "service": "stroke:#43E97B,fill:#F5FFF7,color:#236247,stroke-width:2px;",
+ "http_service": "stroke:#FFB236,fill:#FFFAF4,color:#7A5800,stroke-width:2px;",
+ "http_target": "stroke:#FF6363,fill:#FFF5F5,color:#7C2323,stroke-width:2px;",
+ "port": "stroke:#26C6DA,fill:#E6FAFB,color:#074953,stroke-width:2px,font-size:x-small;",
+ "volume": "stroke:#A259DF,fill:#F4EEFB,color:#320963,stroke-width:2px,font-size:x-small;",
+}
+
+
+@click.command()
+@click.option("--stack", help="name or path of the stack", required=False)
+@click.option(
+ "--deploy-to",
+ help="cluster system to deploy to (compose or k8s or k8s-kind)",
+ default=get_config_setting("deploy-to", "compose"),
+)
+@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.pass_context
+def command(ctx, stack, deploy_to, show_ports, show_http_targets, show_volumes):
+ """generate a mermaid graph of the stack"""
+
+ parent_stack = resolve_stack(stack)
+ chart = Chart(direction=ChartDir.RL)
+
+ for cls, style in _theme.items():
+ chart.add_class_def(ClassDef(cls, f"{style}"))
+
+ def add_stack(stack, show_http_targets=True, show_ports=False, show_volumes=False, parent_graph=None, parent_stack=None):
+ subgraph = Subgraph(stack.name)
+ subgraph.get_id() # we need this to be set
+
+ if stack.is_super_stack():
+ chart.attach_class(subgraph.title, "super_stack")
+ for child in stack.get_required_stacks_paths():
+ child = resolve_stack(child)
+ add_stack(child, show_http_targets, show_ports, show_volumes, parent_graph=subgraph, parent_stack=stack)
+ else:
+ chart.attach_class(subgraph.title, "stack")
+
+ for svc in stack.get_services():
+ svc_node = Node(id=f"{stack.name}-{svc}", title=svc, shape=NodeShape.SUBROUTINE, class_name="service")
+ subgraph.add_node(svc_node)
+
+ shown_http_ports = {}
+ if show_http_targets:
+ http_targets = stack.get_http_proxy_targets()
+ for ht in http_targets:
+ if ht["service"] == svc:
+ title = f":{ht["port"]}"
+ if "k8s" in deploy_to:
+ title = ht.get("path", "/")
+ if parent_stack:
+ http_prefix = parent_stack.http_prefix_for(stack.file_path.parent)
+ if http_prefix and http_prefix != "/":
+ title = f"{http_prefix}{title}"
+ else:
+ shown_http_ports[svc] = ht["port"]
+
+ http_node = Node(
+ id=f"{stack.name}-{svc}-http", title=title, shape=NodeShape.ASSYMETRIC, class_name="http_target"
+ )
+ chart.add_node(http_node)
+ chart.add_link_between(http_node, svc_node)
+ svc_node.class_name = "http_service"
+
+ if show_ports:
+ ports = stack.get_ports().get(svc, [])
+ for port in ports:
+ if svc in shown_http_ports and port == shown_http_ports[svc]:
+ continue
+ port_node = Node(
+ id=f"{stack.name}-{svc}-port-{port}", title=f":{port}", shape=NodeShape.ASSYMETRIC, class_name="port"
+ )
+ chart.add_node(port_node)
+ chart.add_link_between(port_node, svc_node)
+
+ if show_volumes:
+ volumes = stack.get_volumes().get(svc, [])
+ for volume in volumes:
+ volume_node = Node(
+ id=f"{stack.name}-{svc}-volume-{volume}", title=f"{volume}", shape=NodeShape.RECT_ROUND, class_name="volume"
+ )
+ subgraph.add_node(volume_node)
+ subgraph.add_link_between(svc_node, volume_node)
+
+ if parent_graph:
+ for s in parent_graph.subgraphs:
+ if s.id != subgraph.id:
+ chart.add_link_between(s.id, subgraph.id)
+ chart.add_link_between(subgraph.id, s.id)
+ parent_graph.add_subgraph(subgraph)
+ else:
+ chart.add_subgraph(subgraph)
+
+ add_stack(parent_stack, show_http_targets, show_ports, show_volumes)
+
+ out = str(chart)
+ for line in out.splitlines():
+ if "direction" not in line:
+ output_main(line)
diff --git a/src/stack/checklist/checklist.py b/src/stack/checklist/checklist.py
index b3dd1a3..d3ec904 100644
--- a/src/stack/checklist/checklist.py
+++ b/src/stack/checklist/checklist.py
@@ -42,7 +42,7 @@
from stack.util import get_yaml
-def constainer_dispostion(parent_stack, image_registry, git_ssh):
+def container_disposition(parent_stack, image_registry, git_ssh):
ret = {}
required_stacks = parent_stack.get_required_stacks_paths()
@@ -149,7 +149,7 @@ def command(ctx, stack, image_registry, git_ssh):
"""check if stack containers are ready"""
stack = resolve_stack(stack)
- what_needs_done = constainer_dispostion(stack, image_registry, git_ssh)
+ what_needs_done = container_disposition(stack, image_registry, git_ssh)
padding = 8
max_name_len = 0
diff --git a/src/stack/deploy/stack.py b/src/stack/deploy/stack.py
index b082c80..6769dbb 100644
--- a/src/stack/deploy/stack.py
+++ b/src/stack/deploy/stack.py
@@ -180,6 +180,17 @@ def get_ports(self):
ports[svc_name] = [str(x) for x in svc[constants.ports_key]]
return ports
+ def get_volumes(self):
+ volumes = {}
+ pods = self.get_pod_list()
+ for pod in pods:
+ parsed_pod_file = self.load_pod_file(pod)
+ if constants.services_key in parsed_pod_file:
+ for svc_name, svc in parsed_pod_file[constants.services_key].items():
+ if constants.volumes_key in svc:
+ volumes[svc_name] = svc[constants.volumes_key]
+ return volumes
+
def get_http_proxy_targets(self, prefix=None):
if prefix == "/":
prefix = None
@@ -280,6 +291,20 @@ def get_plugin_code_paths(self) -> List[Path]:
return list(result)
+ def http_prefix_for(self, stack_path_or_name):
+ if not self.is_super_stack():
+ return None
+
+ if not os.path.exists(str(stack_path_or_name)):
+ stack_path_or_name = resolve_stack(stack_path_or_name).file_path.parent
+
+ stacks = self.get_required_stacks()
+ for s in stacks:
+ stack_path = determine_fs_path_for_stack(s[constants.ref_key], s[constants.path_key])
+ if stack_path == stack_path_or_name:
+ return s.get(constants.http_proxy_prefix_key, None)
+ return None
+
def __str__(self):
return str(self.__dict__)
@@ -386,6 +411,13 @@ def locate_single_stack(stack_name, search_path=get_dev_root_path(), fail_on_mul
def resolve_stack(stack_name):
if not stack_name:
error_exit("stack name cannot be empty")
+
+ if isinstance(stack_name, Stack):
+ return stack_name
+
+ if isinstance(stack_name, Path):
+ stack_name = str(stack_name)
+
stack = None
if stack_name.startswith("/") or (os.path.exists(stack_name) and os.path.isdir(stack_name)):
stack = get_parsed_stack_config(stack_name)
diff --git a/src/stack/main.py b/src/stack/main.py
index 4fcd5b1..335e641 100644
--- a/src/stack/main.py
+++ b/src/stack/main.py
@@ -1,6 +1,5 @@
# Copyright © 2022, 2023 Vulcanize
# Copyright © 2025 Bozeman Pass, Inc.
-import os
# 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
@@ -16,6 +15,7 @@
# along with this program. If not, see .
import click
+import os
import sys
from stack import opts
@@ -23,6 +23,7 @@
from stack import version
from stack.build import build, prepare
+from stack.chart import chart
from stack.checklist import list_stack, checklist
from stack.cli_util import StackCLI, load_subcommands_from_stack
from stack.command_types import CommandOptions
@@ -79,6 +80,7 @@ def cli(ctx, profile, quiet, verbose, log_file, dry_run, debug, stack):
cli.add_command(build.command, "build")
+cli.add_command(chart.command, "chart")
cli.add_command(checklist.command, "checklist")
cli.add_command(config.command, "config")
cli.add_command(deployment_create.create, "deploy")