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