# Copyright (C) 2022 Mohamed El Mouctar HAIDARA # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json import ntpath import os import sys from abc import ABC from ansible.cli import CLI from ansible.cli.arguments import option_helpers from ansible.errors import AnsibleOptionsError from ansible.release import __version__ as ansible_version from ansible.utils.display import Display, initialize_locale from ansibleplaybookgrapher import __prog__, __version__ from ansibleplaybookgrapher.graphbuilder import ( OPEN_PROTOCOL_HANDLERS, Grapher, ) from ansibleplaybookgrapher.postprocessor import GraphVizPostProcessor # The display is a singleton. This instruction will NOT return a new instance. # We explicitly set the verbosity after the init. display = Display() def get_cli_class(): """ Utility function to return the class to use as CLI :return: """ return PlaybookGrapherCLI class GrapherCLI(CLI, ABC): """ An abstract class to be implemented by the different Grapher CLIs. """ def run(self): super().run() # Required to fix the warning "ansible.utils.display.initialize_locale has not been called..." initialize_locale() display.verbosity = self.options.verbosity grapher = Grapher(self.options.playbook_filenames) grapher.parse( include_role_tasks=self.options.include_role_tasks, tags=self.options.tags, skip_tags=self.options.skip_tags, group_roles_by_name=self.options.group_roles_by_name, ) digraph = grapher.graph( open_protocol_handler=self.options.open_protocol_handler, open_protocol_custom_formats=self.options.open_protocol_custom_formats, ) display.display("Rendering the graph...") svg_path = digraph.render( cleanup=not self.options.save_dot_file, format="svg", filename=self.options.output_filename, view=self.options.view, ) post_processor = GraphVizPostProcessor(svg_path=svg_path) display.v("Post processing the SVG...") post_processor.post_process(grapher.playbook_nodes) post_processor.write() display.display(f"The graph has been exported to {svg_path}", color="green") if self.options.save_dot_file: # add .dot extension. The render doesn't add an extension final_name = self.options.output_filename + ".dot" os.rename(self.options.output_filename, final_name) display.display(f"Graphviz dot file has been exported to {final_name}") return svg_path class PlaybookGrapherCLI(GrapherCLI): """ The dedicated playbook grapher CLI """ def __init__(self, args, callback=None): super().__init__(args=args, callback=callback) # We keep the old options as instance attribute for backward compatibility for the grapher CLI. # From Ansible 2.8, they remove this instance attribute 'options' and use a global context instead. # But this may change in the future: # https://github.com/ansible/ansible/blob/bcb64054edaa7cf636bd38b8ab0259f6fb93f3f9/lib/ansible/context.py#L8 self.options = None def _add_my_options(self): """ Add some of my options to the parser :return: """ self.parser.prog = __prog__ self.parser.add_argument( "-i", "--inventory", dest="inventory", action="append", help="specify inventory host path or comma separated host list.", ) self.parser.add_argument( "--include-role-tasks", dest="include_role_tasks", action="store_true", default=False, help="Include the tasks of the role in the graph.", ) self.parser.add_argument( "-s", "--save-dot-file", dest="save_dot_file", action="store_true", default=False, help="Save the dot file used to generate the graph.", ) self.parser.add_argument( "--view", action="store_true", default=False, help="Automatically open the resulting SVG file with your system’s default viewer application for the file type", ) self.parser.add_argument( "-o", "--output-file-name", dest="output_filename", help="Output filename without the '.svg' extension. Default: .svg", ) self.parser.add_argument( "--open-protocol-handler", dest="open_protocol_handler", choices=list(OPEN_PROTOCOL_HANDLERS.keys()), default="default", help="""The protocol to use to open the nodes when double-clicking on them in your SVG viewer. Your SVG viewer must support double-click and Javascript. The supported values are 'default', 'vscode' and 'custom'. For 'default', the URL will be the path to the file or folders. When using a browser, it will open or download them. For 'vscode', the folders and files will be open with VSCode. For 'custom', you need to set a custom format with --open-protocol-custom-formats. """, ) self.parser.add_argument( "--open-protocol-custom-formats", dest="open_protocol_custom_formats", default=None, help="""The custom formats to use as URLs for the nodes in the graph. Required if --open-protocol-handler is set to custom. You should provide a JSON formatted string like: {"file": "", "folder": ""}. Example: If you want to open folders (roles) inside the browser and files (tasks) in vscode, set this to '{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}' """, ) self.parser.add_argument( "--group-roles-by-name", action="store_true", default=False, help="When rendering the graph, only a single role will be display for all roles having the same names.", ) self.parser.add_argument( "--version", action="version", version=f"{__prog__} {__version__} (with ansible {ansible_version})", ) self.parser.add_argument( "playbook_filenames", help="Playbook(s) to graph", metavar="playbooks", nargs="+", ) # Use ansible helper to add some default options also option_helpers.add_subset_options(self.parser) option_helpers.add_vault_options(self.parser) option_helpers.add_runtask_options(self.parser) def init_parser(self, usage="", desc=None, epilog=None): super().init_parser( usage=f"{__prog__} [options] playbook.yml", desc="Make graphs from your Ansible Playbooks.", epilog=epilog, ) self._add_my_options() def post_process_args(self, options): options = super().post_process_args(options) # init the options self.options = options if self.options.output_filename is None: # use the first playbook name (without the extension) as output filename self.options.output_filename = os.path.splitext( ntpath.basename(self.options.playbook_filenames[0]) )[0] if self.options.open_protocol_handler == "custom": self.validate_open_protocol_custom_formats() return options def validate_open_protocol_custom_formats(self): """ Validate the provided open protocol format :return: """ error_msg = 'Make sure to provide valid formats. Example: {"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}' format_str = self.options.open_protocol_custom_formats if not format_str: raise AnsibleOptionsError( "When the protocol handler is to set to custom, you must provide the formats to " "use with --open-protocol-custom-formats." ) try: format_dict = json.loads(format_str) except Exception as e: display.error( f"{type(e).__name__} when reading the provided formats '{format_str}': {e}" ) display.error(error_msg) sys.exit(1) if "file" not in format_dict or "folder" not in format_dict: display.error( f"The field 'file' or 'folder' is missing from the provided format '{format_str}'" ) display.error(error_msg) sys.exit(1) # Replace the string with a dict self.options.open_protocol_custom_formats = format_dict def main(args=None): args = args or sys.argv cli = get_cli_class()(args) cli.run() if __name__ == "__main__": main(sys.argv)