mirror of
https://github.com/haidaraM/ansible-playbook-grapher
synced 2025-02-20 05:28:24 +00:00
feat: Add support for a JSON renderer (#193)
Co-authored-by: haidaraM <haidaraM@users.noreply.github.com>
This commit is contained in:
parent
34e0aef74b
commit
90f5a30cb7
18 changed files with 1064 additions and 184 deletions
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
- uses: psf/black@stable
|
||||
with:
|
||||
version: "~= 23.0" # https://black.readthedocs.io/en/stable/integrations/github_actions.html
|
||||
version: "~= 24.0" # https://black.readthedocs.io/en/stable/integrations/github_actions.html
|
||||
options: ""
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -108,6 +108,7 @@ venv.bak/
|
|||
.idea
|
||||
.vagrant
|
||||
Vagrantfile
|
||||
generated-svgs
|
||||
generated-mermaids
|
||||
tests/generated-svgs
|
||||
tests/generated-mermaids/
|
||||
tests/generated-jsons
|
||||
**/.DS_Store
|
8
Makefile
8
Makefile
|
@ -21,8 +21,12 @@ deploy_test: clean build
|
|||
test_install: build
|
||||
@./tests/test_install.sh $(VIRTUALENV_DIR) $(ANSIBLE_CORE_VERSION)
|
||||
|
||||
test:
|
||||
cd tests && pytest test_cli.py test_utils.py test_parser.py test_graph_model.py test_graphviz_postprocessor.py test_graphviz_renderer.py test_mermaid_renderer.py
|
||||
fmt:
|
||||
black .
|
||||
|
||||
test: fmt
|
||||
# Due to some side effects with Ansible, we have to run the tests in a certain order
|
||||
cd tests && pytest test_cli.py test_utils.py test_parser.py test_graph_model.py test_graphviz_postprocessor.py test_graphviz_renderer.py test_mermaid_renderer.py test_json_renderer.py
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
|
|
215
README.md
215
README.md
|
@ -5,19 +5,26 @@
|
|||
[](https://coveralls.io/github/haidaraM/ansible-playbook-grapher?branch=main)
|
||||
|
||||
[ansible-playbook-grapher](https://github.com/haidaraM/ansible-playbook-grapher) is a command line tool to create a
|
||||
graph representing your Ansible playbook plays, tasks and roles. The aim of this project is to have an overview of your
|
||||
playbook.
|
||||
graph representing your Ansible playbook, plays, tasks and roles. The aim of this project is to have an overview of your
|
||||
playbooks that you can use as documentation.
|
||||
|
||||
Inspired by [Ansible Inventory Grapher](https://github.com/willthames/ansible-inventory-grapher).
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple [rendering formats](#renderers): graphviz, mermaid and JSON.
|
||||
- Automatically find all your installed roles and collection.
|
||||
- Native support of Ansible filters based on tags.
|
||||
- Variables interpolation (when possible).
|
||||
- Support `import_*` and `include_*`.
|
||||
- Multiple flags to hide empty plays, group roles by name etc...
|
||||
|
||||
The following features are available when opening the SVGs in a browser (recommended) or a viewer that supports
|
||||
JavaScript:
|
||||
|
||||
- Highlighting of all the related nodes of a given node when clicking or hovering. Example: Click on a role to select
|
||||
all its tasks when `--include-role-tasks` is set.
|
||||
- A double click on a node opens the corresponding file or folder depending whether if it's a playbook, a play, a task
|
||||
- A double click on a node opens the corresponding file or folder depending on whether it's a playbook, a play, a task
|
||||
or a role. By default, the browser will open folders and download files since it may not be able to render the YAML
|
||||
file.
|
||||
Optionally, you can
|
||||
|
@ -26,8 +33,7 @@ JavaScript:
|
|||
the files for the others nodes. The cursor will be at the task exact position in the file.
|
||||
Lastly, you can provide your own protocol formats
|
||||
with `--open-protocol-handler custom --open-protocol-custom-formats '{}'`. See the help
|
||||
and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/12cee0fbd59ffbb706731460e301f0b886515357/ansibleplaybookgrapher/graphbuilder.py#L33-L42)
|
||||
- Filer tasks based on tags
|
||||
and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/34e0aef74b82808dceb6ccfbeb333c0b531eac12/ansibleplaybookgrapher/renderer/__init__.py#L32-L41)
|
||||
- Export the dot file used to generate the graph with Graphviz.
|
||||
|
||||
## Prerequisites
|
||||
|
@ -38,7 +44,7 @@ JavaScript:
|
|||
some versions of ansible-core which are not necessarily installed in your environment and may cause issues if you use
|
||||
some older versions of Ansible (
|
||||
since `ansible` [package has been split](https://www.ansible.com/blog/ansible-3.0.0-qa)).
|
||||
- **Graphviz**: The tool used to generate the graph in SVG.
|
||||
- **Graphviz**: The tool used to generate the graph in SVG. Optional if you don't plan to use the `graphviz` renderer.
|
||||
```shell script
|
||||
sudo apt-get install graphviz # or yum install or brew install
|
||||
```
|
||||
|
@ -52,7 +58,7 @@ for the supported Ansible version.
|
|||
pip install ansible-playbook-grapher
|
||||
```
|
||||
|
||||
You can also install the unpublished version from GitHub direction. Examples:
|
||||
You can also install the unpublished version from GitHub direction. Examples:
|
||||
|
||||
```shell
|
||||
# Install the version from the main branch
|
||||
|
@ -66,11 +72,11 @@ pip install "ansible-playbook-grapher @ git+https://github.com/haidaraM/ansible-
|
|||
|
||||
At the time of writing, two renderers are supported:
|
||||
|
||||
1. `graphviz` (default): Generate the graph in SVG. It has more features and is more tested: open protocol,
|
||||
highlight linked nodes...
|
||||
1. `graphviz` (default): Generate the graph in SVG. Has more features than the other renderers.
|
||||
2. `mermaid-flowchart`: Generate the graph in [Mermaid](https://mermaid.js.org/syntax/flowchart.html) format. You can
|
||||
directly embed the graph in your markdown and GitHub (
|
||||
and [other integrations](https://mermaid.js.org/ecosystem/integrations.html)) will render it. **Early support**.
|
||||
and [other integrations](https://mermaid.js.org/ecosystem/integrations.html)) will render it.
|
||||
3. `json`: Generate a JSON representation of the graph to be used by other tools.
|
||||
|
||||
If you are interested to support more renderers, feel free to create an issue or raise a PR based on the existing
|
||||
renderers.
|
||||
|
@ -83,19 +89,19 @@ ansible-playbook-grapher tests/fixtures/example.yml
|
|||
|
||||

|
||||
|
||||
```bash
|
||||
```shell
|
||||
ansible-playbook-grapher --include-role-tasks tests/fixtures/with_roles.yml
|
||||
```
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
```shell
|
||||
ansible-playbook-grapher tests/fixtures/with_block.yml
|
||||
```
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
```shell
|
||||
ansible-playbook-grapher --include-role-tasks --renderer mermaid-flowchart tests/fixtures/multi-plays.yml
|
||||
```
|
||||
|
||||
|
@ -292,7 +298,83 @@ flowchart LR
|
|||
%% End of the playbook 'tests/fixtures/multi-plays.yml'
|
||||
```
|
||||
|
||||
Note on block: Since `block`s are logical group of tasks, the conditional `when` is not displayed on the edges pointing
|
||||
```shell
|
||||
ansible-playbook-grapher --renderer json tests/fixtures/simple_playbook.yml
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"playbooks": [
|
||||
{
|
||||
"type": "PlaybookNode",
|
||||
"id": "playbook_e4dc5cb3",
|
||||
"name": "tests/fixtures/simple_playbook.yml",
|
||||
"when": "",
|
||||
"index": 1,
|
||||
"location": {
|
||||
"type": "file",
|
||||
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
|
||||
"line": 1,
|
||||
"column": 1
|
||||
},
|
||||
"plays": [
|
||||
{
|
||||
"type": "PlayNode",
|
||||
"id": "play_1c544613",
|
||||
"name": "Play: all (0)",
|
||||
"when": "",
|
||||
"index": 1,
|
||||
"location": {
|
||||
"type": "file",
|
||||
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
|
||||
"line": 2,
|
||||
"column": 3
|
||||
},
|
||||
"post_tasks": [
|
||||
{
|
||||
"type": "TaskNode",
|
||||
"id": "post_task_a9b2e9ac",
|
||||
"name": "Post task 1",
|
||||
"when": "",
|
||||
"index": 1,
|
||||
"location": {
|
||||
"type": "file",
|
||||
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
|
||||
"line": 4,
|
||||
"column": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "TaskNode",
|
||||
"id": "post_task_61204621",
|
||||
"name": "Post task 2",
|
||||
"when": "",
|
||||
"index": 2,
|
||||
"location": {
|
||||
"type": "file",
|
||||
"path": "/Users/mohamedelmouctarhaidara/projects/ansible-playbook-grapher/tests/fixtures/simple_playbook.yml",
|
||||
"line": 7,
|
||||
"column": 7
|
||||
}
|
||||
}
|
||||
],
|
||||
"pre_tasks": [],
|
||||
"roles": [],
|
||||
"tasks": [],
|
||||
"hosts": [],
|
||||
"colors": {
|
||||
"main": "#585874",
|
||||
"font": "#ffffff"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note on block: Since a `block` is a logical group of tasks, the conditional `when` is not displayed on the edges pointing
|
||||
to them but on the tasks inside the block. This
|
||||
mimics [Ansible behavior](https://docs.ansible.com/ansible/latest/user_guide/playbooks_blocks.html#grouping-tasks-with-blocks)
|
||||
regarding the blocks.
|
||||
|
@ -302,19 +384,11 @@ regarding the blocks.
|
|||
The available options:
|
||||
|
||||
```
|
||||
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY]
|
||||
[--include-role-tasks] [-s] [--view]
|
||||
[-o OUTPUT_FILENAME]
|
||||
[--open-protocol-handler {default,vscode,custom}]
|
||||
[--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS]
|
||||
[--group-roles-by-name]
|
||||
[--renderer {graphviz,mermaid-flowchart}]
|
||||
[--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE]
|
||||
[--renderer-mermaid-orientation {TD,RL,BT,RL,LR}]
|
||||
[--version] [-t TAGS] [--skip-tags SKIP_TAGS]
|
||||
[--vault-id VAULT_IDS]
|
||||
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
|
||||
[-e EXTRA_VARS]
|
||||
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY] [--include-role-tasks] [-s] [--view] [-o OUTPUT_FILENAME] [--open-protocol-handler {default,vscode,custom}]
|
||||
[--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS] [--group-roles-by-name] [--renderer {graphviz,mermaid-flowchart,json}]
|
||||
[--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE] [--renderer-mermaid-orientation {TD,RL,BT,RL,LR}] [--version]
|
||||
[--hide-plays-without-roles] [--hide-empty-plays] [-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
|
||||
[-J | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
|
||||
playbooks [playbooks ...]
|
||||
|
||||
Make graphs from your Ansible Playbooks.
|
||||
|
@ -323,74 +397,51 @@ positional arguments:
|
|||
playbooks Playbook(s) to graph
|
||||
|
||||
options:
|
||||
--ask-vault-password, --ask-vault-pass
|
||||
ask for vault password
|
||||
--group-roles-by-name
|
||||
When rendering the graph, only a single role will be
|
||||
display for all roles having the same names. Default:
|
||||
False
|
||||
--include-role-tasks Include the tasks of the role in the graph.
|
||||
When rendering the graph (graphviz and mermaid), only a single role will be displayed for all roles having the same names. Default: False
|
||||
--hide-empty-plays Hide the plays that end up with no tasks in the graph (after applying the tags filter).
|
||||
--hide-plays-without-roles
|
||||
Hide the plays that end up with no roles in the graph (after applying the tags filter). Only roles at the play level and include_role as tasks are
|
||||
considered (no import_role).
|
||||
--include-role-tasks Include the tasks of the roles in the graph. Applied when parsing the playbooks.
|
||||
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
|
||||
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 it to: '{"file":
|
||||
"vscode://file/{path}:{line}:{column}", "folder":
|
||||
"{path}"}'. path: the absolute path to the file
|
||||
containing the the plays/tasks/roles. line/column: the
|
||||
position of the plays/tasks/roles in the file. You can
|
||||
optionally add the attribute "remove_from_path" to
|
||||
remove some parts of the path if you want relative
|
||||
paths.
|
||||
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 it to:
|
||||
'{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'. path: the absolute path to the file containing the the plays/tasks/roles.
|
||||
line/column: the position of the plays/tasks/roles in the file. You can optionally add the attribute "remove_from_path" to remove some parts of the
|
||||
path if you want relative paths.
|
||||
--open-protocol-handler {default,vscode,custom}
|
||||
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.
|
||||
--renderer {graphviz,mermaid-flowchart}
|
||||
The renderer to use to generate the graph. Default:
|
||||
graphviz
|
||||
The protocol to use to open the nodes when double-clicking on them in your SVG viewer (only for graphviz). 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.
|
||||
--renderer {graphviz,mermaid-flowchart,json}
|
||||
The renderer to use to generate the graph. Default: graphviz
|
||||
--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE
|
||||
The directive for the mermaid renderer. Can be used to
|
||||
customize the output: fonts, theme, curve etc. More
|
||||
info at https://mermaid.js.org/config/directives.html.
|
||||
Default: '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
|
||||
The directive for the mermaid renderer. Can be used to customize the output: fonts, theme, curve etc. More info at
|
||||
https://mermaid.js.org/config/directives.html. Default: '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
|
||||
--renderer-mermaid-orientation {TD,RL,BT,RL,LR}
|
||||
The orientation of the flow chart. Default: 'LR'
|
||||
--skip-tags SKIP_TAGS
|
||||
only run plays and tasks whose tags do not match these
|
||||
values
|
||||
--vault-id VAULT_IDS the vault identity to use
|
||||
only run plays and tasks whose tags do not match these values. This argument may be specified multiple times.
|
||||
--vault-id VAULT_IDS the vault identity to use. This argument may be specified multiple times.
|
||||
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
|
||||
vault password file
|
||||
--version show program's version number and exit
|
||||
--view Automatically open the resulting SVG file with your
|
||||
system’s default viewer application for the file type
|
||||
--version
|
||||
--view Automatically open the resulting SVG file with your system’s default viewer application for the file type
|
||||
-J, --ask-vault-password, --ask-vault-pass
|
||||
ask for vault password
|
||||
-e EXTRA_VARS, --extra-vars EXTRA_VARS
|
||||
set additional variables as key=value or YAML/JSON, if
|
||||
filename prepend with @
|
||||
set additional variables as key=value or YAML/JSON, if filename prepend with @. This argument may be specified multiple times.
|
||||
-h, --help show this help message and exit
|
||||
-i INVENTORY, --inventory INVENTORY
|
||||
specify inventory host path or comma separated host
|
||||
list.
|
||||
specify inventory host path or comma separated host list. This argument may be specified multiple times.
|
||||
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
|
||||
Output filename without the '.svg' extension. Default:
|
||||
<playbook>.svg
|
||||
Output filename without the '.svg' extension (for graphviz), '.mmd' for Mermaid or `.json`. The extension will be added automatically.
|
||||
-s, --save-dot-file Save the graphviz dot file used to generate the graph.
|
||||
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
|
||||
-v, --verbose Causes Ansible to print more debug messages. Adding
|
||||
multiple -v will increase the verbosity, the builtin
|
||||
plugins currently evaluate up to -vvvvvv. A reasonable
|
||||
level to start is -vvv, connection debugging might
|
||||
require -vvvv.
|
||||
-t TAGS, --tags TAGS only run plays and tasks tagged with these values. This argument may be specified multiple times.
|
||||
-v, --verbose Causes Ansible to print more debug messages. Adding multiple -v will increase the verbosity, the builtin plugins currently evaluate up to -vvvvvv. A
|
||||
reasonable level to start is -vvv, connection debugging might require -vvvv. This argument may be specified multiple times.
|
||||
|
||||
```
|
||||
|
||||
|
@ -435,7 +486,7 @@ make test # run all tests
|
|||
```
|
||||
|
||||
The graphs are generated in the folder `tests/generated-svgs`. They are also generated as artefacts
|
||||
in [Github Actions](https://github.com/haidaraM/ansible-playbook-grapher/actions). Feel free to look at them when
|
||||
in [GitHub Actions](https://github.com/haidaraM/ansible-playbook-grapher/actions). Feel free to look at them when
|
||||
submitting PRs.
|
||||
|
||||
### Lint and format
|
||||
|
|
|
@ -23,10 +23,11 @@ from ansible.errors import AnsibleOptionsError
|
|||
from ansible.release import __version__ as ansible_version
|
||||
from ansible.utils.display import Display
|
||||
|
||||
from ansibleplaybookgrapher.grapher import Grapher
|
||||
from ansibleplaybookgrapher import __prog__, __version__
|
||||
from ansibleplaybookgrapher.grapher import Grapher
|
||||
from ansibleplaybookgrapher.renderer import OPEN_PROTOCOL_HANDLERS
|
||||
from ansibleplaybookgrapher.renderer.graphviz import GraphvizRenderer
|
||||
from ansibleplaybookgrapher.renderer.json import JSONRenderer
|
||||
from ansibleplaybookgrapher.renderer.mermaid import (
|
||||
MermaidFlowChartRenderer,
|
||||
DEFAULT_DIRECTIVE as MERMAID_DEFAULT_DIRECTIVE,
|
||||
|
@ -54,6 +55,7 @@ class PlaybookGrapherCLI(CLI):
|
|||
self.options = None
|
||||
|
||||
def run(self):
|
||||
# FIXME: run should not return anything.
|
||||
super().run()
|
||||
|
||||
display.verbosity = self.options.verbosity
|
||||
|
@ -65,38 +67,54 @@ class PlaybookGrapherCLI(CLI):
|
|||
group_roles_by_name=self.options.group_roles_by_name,
|
||||
)
|
||||
|
||||
if self.options.renderer == "graphviz":
|
||||
renderer = GraphvizRenderer(
|
||||
playbook_nodes=playbook_nodes,
|
||||
roles_usage=roles_usage,
|
||||
)
|
||||
output_path = renderer.render(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
output_filename=self.options.output_filename,
|
||||
view=self.options.view,
|
||||
save_dot_file=self.options.save_dot_file,
|
||||
hide_empty_plays=self.options.hide_empty_plays,
|
||||
hide_plays_without_roles=self.options.hide_plays_without_roles,
|
||||
)
|
||||
match self.options.renderer:
|
||||
case "graphviz":
|
||||
renderer = GraphvizRenderer(
|
||||
playbook_nodes=playbook_nodes,
|
||||
roles_usage=roles_usage,
|
||||
)
|
||||
return renderer.render(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
output_filename=self.options.output_filename,
|
||||
view=self.options.view,
|
||||
save_dot_file=self.options.save_dot_file,
|
||||
hide_empty_plays=self.options.hide_empty_plays,
|
||||
hide_plays_without_roles=self.options.hide_plays_without_roles,
|
||||
)
|
||||
|
||||
return output_path
|
||||
else:
|
||||
renderer = MermaidFlowChartRenderer(
|
||||
playbook_nodes=playbook_nodes,
|
||||
roles_usage=roles_usage,
|
||||
)
|
||||
output_path = renderer.render(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
output_filename=self.options.output_filename,
|
||||
view=self.options.view,
|
||||
directive=self.options.renderer_mermaid_directive,
|
||||
orientation=self.options.renderer_mermaid_orientation,
|
||||
hide_empty_plays=self.options.hide_empty_plays,
|
||||
hide_plays_without_roles=self.options.hide_plays_without_roles,
|
||||
)
|
||||
return output_path
|
||||
case "mermaid-flowchart":
|
||||
renderer = MermaidFlowChartRenderer(
|
||||
playbook_nodes=playbook_nodes,
|
||||
roles_usage=roles_usage,
|
||||
)
|
||||
return renderer.render(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
output_filename=self.options.output_filename,
|
||||
view=self.options.view,
|
||||
directive=self.options.renderer_mermaid_directive,
|
||||
orientation=self.options.renderer_mermaid_orientation,
|
||||
hide_empty_plays=self.options.hide_empty_plays,
|
||||
hide_plays_without_roles=self.options.hide_plays_without_roles,
|
||||
)
|
||||
|
||||
case "json":
|
||||
renderer = JSONRenderer(playbook_nodes, roles_usage)
|
||||
return renderer.render(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
output_filename=self.options.output_filename,
|
||||
view=self.options.view,
|
||||
hide_empty_plays=self.options.hide_empty_plays,
|
||||
hide_plays_without_roles=self.options.hide_plays_without_roles,
|
||||
)
|
||||
|
||||
case _:
|
||||
# Likely a bug if we are here
|
||||
raise AnsibleOptionsError(
|
||||
f"Unknown renderer '{self.options.renderer}'. This is likely a bug that should be reported."
|
||||
)
|
||||
|
||||
def _add_my_options(self):
|
||||
"""
|
||||
|
@ -141,7 +159,8 @@ class PlaybookGrapherCLI(CLI):
|
|||
"-o",
|
||||
"--output-file-name",
|
||||
dest="output_filename",
|
||||
help="Output filename without the '.svg' extension. Default: <playbook>.svg",
|
||||
help="Output filename without the '.svg' extension (for graphviz), '.mmd' for Mermaid or `.json`. "
|
||||
"The extension will be added automatically.",
|
||||
)
|
||||
|
||||
self.parser.add_argument(
|
||||
|
@ -150,7 +169,7 @@ class PlaybookGrapherCLI(CLI):
|
|||
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.
|
||||
viewer (only for graphviz). 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.
|
||||
|
@ -180,12 +199,12 @@ class PlaybookGrapherCLI(CLI):
|
|||
"--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. Default: %(default)s",
|
||||
help="When rendering the graph (graphviz and mermaid), only a single role will be displayed for all roles having the same names. Default: %(default)s",
|
||||
)
|
||||
|
||||
self.parser.add_argument(
|
||||
"--renderer",
|
||||
choices=["graphviz", "mermaid-flowchart"],
|
||||
choices=["graphviz", "mermaid-flowchart", "json"],
|
||||
default="graphviz",
|
||||
help="The renderer to use to generate the graph. Default: %(default)s",
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
|
||||
# Copyright (C) 2024 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
|
||||
|
@ -14,6 +14,7 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, List, Set, Tuple, Optional
|
||||
|
||||
from ansibleplaybookgrapher.utils import generate_id, get_play_colors
|
||||
|
@ -35,6 +36,25 @@ class LoopMixin:
|
|||
return self.raw_object.loop is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeLocation:
|
||||
"""
|
||||
The node location on the filesystem.
|
||||
The location can be a folder (for roles) or a specific line and column inside a file
|
||||
"""
|
||||
|
||||
type: str # file or folder
|
||||
path: Optional[str] = None
|
||||
line: Optional[int] = None
|
||||
column: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.type not in ["folder", "file"]:
|
||||
raise ValueError(
|
||||
f"Type '{self.type}' not supported. Valid values: file, folder."
|
||||
)
|
||||
|
||||
|
||||
class Node:
|
||||
"""
|
||||
A node in the graph. Everything of the final graph is a node: playbook, plays, tasks and roles.
|
||||
|
@ -64,20 +84,23 @@ class Node:
|
|||
self.when = when
|
||||
self.raw_object = raw_object
|
||||
|
||||
# Get the node position in the parsed files. Format: (path,line,column)
|
||||
self.path = self.line = self.column = None
|
||||
self.set_position()
|
||||
self.location: Optional[NodeLocation] = None
|
||||
self.set_location()
|
||||
|
||||
# The index of this node in the parent node if it has one (starting from 1)
|
||||
self.index: Optional[int] = index
|
||||
|
||||
def set_position(self):
|
||||
def set_location(self):
|
||||
"""
|
||||
Set the path of this based on the raw object. Not all objects have path
|
||||
Set the location of this node based on the raw object. Not all objects have path
|
||||
:return:
|
||||
"""
|
||||
if self.raw_object and self.raw_object.get_ds():
|
||||
self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos
|
||||
path, line, column = self.raw_object.get_ds().ansible_pos
|
||||
# By default, it's a file
|
||||
self.location = NodeLocation(
|
||||
type="file", path=path, line=line, column=column
|
||||
)
|
||||
|
||||
def get_first_parent_matching_type(self, node_type: type) -> type:
|
||||
"""
|
||||
|
@ -106,6 +129,27 @@ class Node:
|
|||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
def to_dict(self, **kwargs) -> Dict:
|
||||
"""
|
||||
Return a dictionary representation of this node. This representation is not meant to get the original object
|
||||
back.
|
||||
:return:
|
||||
"""
|
||||
data = {
|
||||
"type": type(self).__name__,
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"when": self.when,
|
||||
"index": self.index,
|
||||
}
|
||||
|
||||
if self.location is not None:
|
||||
data["location"] = asdict(self.location)
|
||||
else:
|
||||
data["location"] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CompositeNode(Node):
|
||||
"""
|
||||
|
@ -143,7 +187,7 @@ class CompositeNode(Node):
|
|||
index=index,
|
||||
)
|
||||
self._supported_compositions = supported_compositions or []
|
||||
# The dict will contain the different types of composition.
|
||||
# The dict will contain the different types of composition: plays, tasks, roles...
|
||||
self._compositions = defaultdict(list) # type: Dict[str, List]
|
||||
# Used to count the number of nodes in this composite node
|
||||
self._node_counter = 0
|
||||
|
@ -172,7 +216,7 @@ class CompositeNode(Node):
|
|||
"""
|
||||
if target_composition not in self._supported_compositions:
|
||||
raise Exception(
|
||||
f"The target composition '{target_composition}' is unknown. Supported are: {self._supported_compositions}"
|
||||
f"The target composition '{target_composition}' is unknown. Supported ones are: {self._supported_compositions}"
|
||||
)
|
||||
|
||||
return self._compositions[target_composition]
|
||||
|
@ -248,6 +292,23 @@ class CompositeNode(Node):
|
|||
|
||||
return False
|
||||
|
||||
def to_dict(self, **kwargs) -> Dict:
|
||||
"""
|
||||
Return a dictionary representation of this composite node. This representation is not meant to get the
|
||||
original object back.
|
||||
:return:
|
||||
"""
|
||||
node_dict = super().to_dict(**kwargs)
|
||||
|
||||
for composition, nodes in self._compositions.items():
|
||||
nodes_dict_list = []
|
||||
for node in nodes:
|
||||
nodes_dict_list.append(node.to_dict(**kwargs))
|
||||
|
||||
node_dict[composition] = nodes_dict_list
|
||||
|
||||
return node_dict
|
||||
|
||||
|
||||
class CompositeTasksNode(CompositeNode):
|
||||
"""
|
||||
|
@ -313,15 +374,15 @@ class PlaybookNode(CompositeNode):
|
|||
supported_compositions=["plays"],
|
||||
)
|
||||
|
||||
def set_position(self):
|
||||
def set_location(self):
|
||||
"""
|
||||
Playbooks only have path as position
|
||||
:return:
|
||||
"""
|
||||
# Since the playbook is the whole file, the set the position as the beginning of the file
|
||||
self.path = os.path.join(os.getcwd(), self.name)
|
||||
self.line = 1
|
||||
self.column = 1
|
||||
self.location = NodeLocation(
|
||||
type="file", path=os.path.join(os.getcwd(), self.name), line=1, column=1
|
||||
)
|
||||
|
||||
def plays(
|
||||
self, exclude_empty: bool = False, exclude_without_roles: bool = False
|
||||
|
@ -363,6 +424,31 @@ class PlaybookNode(CompositeNode):
|
|||
|
||||
return usages
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
exclude_empty_plays: bool = False,
|
||||
exclude_plays_without_roles: bool = False,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""
|
||||
Return a dictionary representation of this playbook
|
||||
:param exclude_empty_plays: Whether to exclude the empty plays from the result or not
|
||||
:param exclude_plays_without_roles: Whether to exclude the plays that do not have roles
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
playbook_dict = super().to_dict(**kwargs)
|
||||
playbook_dict["plays"] = []
|
||||
|
||||
# We need to explicitly get the plays here to exclude the ones we don't need
|
||||
for play in self.plays(
|
||||
exclude_empty=exclude_empty_plays,
|
||||
exclude_without_roles=exclude_plays_without_roles,
|
||||
):
|
||||
playbook_dict["plays"].append(play.to_dict(**kwargs))
|
||||
|
||||
return playbook_dict
|
||||
|
||||
|
||||
class PlayNode(CompositeNode):
|
||||
"""
|
||||
|
@ -420,6 +506,18 @@ class PlayNode(CompositeNode):
|
|||
def tasks(self) -> List["Node"]:
|
||||
return self.get_nodes("tasks")
|
||||
|
||||
def to_dict(self, **kwargs) -> Dict:
|
||||
"""
|
||||
Return a dictionary representation of this composite node. This representation is not meant to get the
|
||||
original object back.
|
||||
:return:
|
||||
"""
|
||||
data = super().to_dict(**kwargs)
|
||||
data["hosts"] = self.hosts
|
||||
data["colors"] = {"main": self.colors[0], "font": self.colors[1]}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BlockNode(CompositeTasksNode):
|
||||
"""
|
||||
|
@ -506,17 +604,18 @@ class RoleNode(LoopMixin, CompositeTasksNode):
|
|||
index=index,
|
||||
)
|
||||
|
||||
def set_position(self):
|
||||
def set_location(self):
|
||||
"""
|
||||
Retrieve the position depending on whether it's an include_role or not
|
||||
:return:
|
||||
"""
|
||||
if self.raw_object and not self.include_role:
|
||||
# If it's not an include_role, we take the role path which the path to the folder where the role is located
|
||||
# on the disk
|
||||
self.path = self.raw_object._role_path
|
||||
# If it's not an include_role, we take the role path which is the path to the folder where the role
|
||||
# is located on the disk.
|
||||
self.location = NodeLocation(type="folder", path=self.raw_object._role_path)
|
||||
|
||||
else:
|
||||
super().set_position()
|
||||
super().set_location()
|
||||
|
||||
def has_loop(self) -> bool:
|
||||
if not self.include_role:
|
||||
|
@ -524,3 +623,15 @@ class RoleNode(LoopMixin, CompositeTasksNode):
|
|||
return False
|
||||
|
||||
return super().has_loop()
|
||||
|
||||
def to_dict(self, **kwargs) -> Dict:
|
||||
"""
|
||||
Return a dictionary representation of this composite node. This representation is not meant to get the
|
||||
original object back.
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
node_dict = super().to_dict(**kwargs)
|
||||
node_dict["include_role"] = self.include_role
|
||||
|
||||
return node_dict
|
||||
|
|
|
@ -50,7 +50,7 @@ class Grapher:
|
|||
playbook_nodes = []
|
||||
roles_usage: Dict[RoleNode, Set[PlayNode]] = {}
|
||||
|
||||
for playbook_file in self.playbook_filenames:
|
||||
for counter, playbook_file in enumerate(self.playbook_filenames, 1):
|
||||
playbook_parser = PlaybookParser(
|
||||
playbook_filename=playbook_file,
|
||||
tags=tags,
|
||||
|
@ -59,6 +59,7 @@ class Grapher:
|
|||
group_roles_by_name=group_roles_by_name,
|
||||
)
|
||||
playbook_node = playbook_parser.parse()
|
||||
playbook_node.index = counter
|
||||
playbook_nodes.append(playbook_node)
|
||||
|
||||
# Update the usage of the roles
|
||||
|
|
|
@ -167,7 +167,7 @@ class PlaybookParser(BaseParser):
|
|||
add post_tasks
|
||||
:return:
|
||||
"""
|
||||
display.display(f"Parsing playbook {self.playbook_filename}")
|
||||
display.display(f"Parsing the playbook '{self.playbook_filename}'")
|
||||
playbook = Playbook.load(
|
||||
self.playbook_filename,
|
||||
loader=self.data_loader,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
|
||||
# Copyright (C) 2024 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
|
||||
|
@ -122,7 +122,7 @@ class PlaybookBuilder(ABC):
|
|||
)
|
||||
elif isinstance(node, RoleNode):
|
||||
self.build_role(role_node=node, color=color, fontcolor=fontcolor, **kwargs)
|
||||
else: # This is necessarily a TaskNode
|
||||
elif isinstance(node, TaskNode):
|
||||
self.build_task(
|
||||
task_node=node,
|
||||
color=color,
|
||||
|
@ -130,6 +130,10 @@ class PlaybookBuilder(ABC):
|
|||
node_label_prefix=kwargs.pop("node_label_prefix", ""),
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f"Unsupported node type: {type(node)}. This is likely a bug that should be reported"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def build_playbook(
|
||||
|
@ -239,19 +243,19 @@ class PlaybookBuilder(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
def get_node_url(self, node: Node, node_type: str) -> Optional[str]:
|
||||
def get_node_url(self, node: Node) -> Optional[str]:
|
||||
"""
|
||||
Get the node url based on the chosen protocol
|
||||
:param node_type: task or role
|
||||
Get the node url based on the chosen open protocol.
|
||||
|
||||
:param node: the node to get the url for
|
||||
:return:
|
||||
"""
|
||||
if node.path:
|
||||
if node.location and node.location.path:
|
||||
remove_from_path = self.open_protocol_formats.get("remove_from_path", "")
|
||||
path = node.path.replace(remove_from_path, "")
|
||||
path = node.location.path.replace(remove_from_path, "")
|
||||
|
||||
url = self.open_protocol_formats[node_type].format(
|
||||
path=path, line=node.line, column=node.column
|
||||
url = self.open_protocol_formats[node.location.type].format(
|
||||
path=path, line=node.location.line, column=node.location.column
|
||||
)
|
||||
display.vvvv(f"Open protocol URL for node {node}: {url}")
|
||||
return url
|
||||
|
|
|
@ -162,7 +162,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
|
|||
id=task_node.id,
|
||||
tooltip=task_node.name,
|
||||
color=color,
|
||||
URL=self.get_node_url(task_node, "file"),
|
||||
URL=self.get_node_url(task_node),
|
||||
)
|
||||
|
||||
# Edge from parent to task
|
||||
|
@ -212,7 +212,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
|
|||
color=color,
|
||||
fontcolor=fontcolor,
|
||||
labeltooltip=block_node.name,
|
||||
URL=self.get_node_url(block_node, "file"),
|
||||
URL=self.get_node_url(block_node),
|
||||
)
|
||||
|
||||
# The reverse here is a little hack due to how graphviz render nodes inside a cluster by reversing them.
|
||||
|
@ -253,9 +253,9 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
|
|||
self.roles_built.add(role_node)
|
||||
|
||||
if role_node.include_role: # For include_role, we point to a file
|
||||
url = self.get_node_url(role_node, "file")
|
||||
url = self.get_node_url(role_node)
|
||||
else: # For normal role invocation, we point to the folder
|
||||
url = self.get_node_url(role_node, "folder")
|
||||
url = self.get_node_url(role_node)
|
||||
|
||||
plays_using_this_role = self.roles_usage[role_node]
|
||||
if len(plays_using_this_role) > 1:
|
||||
|
@ -301,7 +301,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
|
|||
label=self.playbook_node.name,
|
||||
style="dotted",
|
||||
id=self.playbook_node.id,
|
||||
URL=self.get_node_url(self.playbook_node, "file"),
|
||||
URL=self.get_node_url(self.playbook_node),
|
||||
)
|
||||
|
||||
for play in self.playbook_node.plays(
|
||||
|
@ -337,7 +337,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
|
|||
color=color,
|
||||
fontcolor=play_font_color,
|
||||
tooltip=play_tooltip,
|
||||
URL=self.get_node_url(play_node, "file"),
|
||||
URL=self.get_node_url(play_node),
|
||||
)
|
||||
|
||||
# from playbook to play
|
||||
|
|
156
ansibleplaybookgrapher/renderer/json.py
Normal file
156
ansibleplaybookgrapher/renderer/json.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
# Copyright (C) 2024 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ansible.utils.display import Display
|
||||
|
||||
from ansibleplaybookgrapher.graph_model import (
|
||||
BlockNode,
|
||||
RoleNode,
|
||||
TaskNode,
|
||||
PlayNode,
|
||||
PlaybookNode,
|
||||
)
|
||||
from ansibleplaybookgrapher.renderer import PlaybookBuilder, Renderer
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class JSONRenderer(Renderer):
|
||||
"""
|
||||
A renderer that writes the graph to a JSON file
|
||||
"""
|
||||
|
||||
def render(
|
||||
self,
|
||||
open_protocol_handler: Optional[str],
|
||||
open_protocol_custom_formats: Optional[Dict[str, str]],
|
||||
output_filename: str,
|
||||
view: bool,
|
||||
hide_empty_plays: bool = False,
|
||||
hide_plays_without_roles: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
playbooks = []
|
||||
|
||||
for playbook_node in self.playbook_nodes:
|
||||
json_builder = JSONPlaybookBuilder(playbook_node, open_protocol_handler)
|
||||
json_builder.build_playbook(
|
||||
hide_empty_plays=hide_empty_plays,
|
||||
hide_plays_without_roles=hide_plays_without_roles,
|
||||
)
|
||||
|
||||
playbooks.append(json_builder.json_output)
|
||||
|
||||
output = {
|
||||
"version": 1,
|
||||
"playbooks": playbooks,
|
||||
}
|
||||
|
||||
final_output_path_file = Path(f"{output_filename}.json")
|
||||
# Make the sure the parents directories exist
|
||||
final_output_path_file.parent.mkdir(exist_ok=True, parents=True)
|
||||
dump_str = json.dumps(output, indent=2)
|
||||
final_output_path_file.write_text(dump_str)
|
||||
|
||||
display.display(f"JSON file written to {final_output_path_file}", color="green")
|
||||
|
||||
if view:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(final_output_path_file))
|
||||
else:
|
||||
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
||||
subprocess.call([opener, str(final_output_path_file)])
|
||||
|
||||
return str(final_output_path_file)
|
||||
|
||||
|
||||
class JSONPlaybookBuilder(PlaybookBuilder):
|
||||
def __init__(self, playbook_node: PlaybookNode, open_protocol_handler: str):
|
||||
super().__init__(playbook_node, open_protocol_handler)
|
||||
|
||||
self.json_output = {}
|
||||
|
||||
def build_playbook(
|
||||
self,
|
||||
hide_empty_plays: bool = False,
|
||||
hide_plays_without_roles: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
|
||||
:param hide_empty_plays:
|
||||
:param hide_plays_without_roles:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
display.vvv(
|
||||
f"Converting the playbook '{self.playbook_node.name}' to JSON format"
|
||||
)
|
||||
|
||||
self.json_output = self.playbook_node.to_dict(
|
||||
exclude_empty_plays=hide_empty_plays,
|
||||
exclude_plays_without_roles=hide_plays_without_roles,
|
||||
)
|
||||
|
||||
return json.dumps(self.json_output)
|
||||
|
||||
def build_play(self, play_node: PlayNode, **kwargs):
|
||||
"""
|
||||
Not needed
|
||||
:param play_node:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def build_task(self, task_node: TaskNode, color: str, fontcolor: str, **kwargs):
|
||||
"""
|
||||
Not needed
|
||||
:param task_node:
|
||||
:param color:
|
||||
:param fontcolor:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def build_role(self, role_node: RoleNode, color: str, fontcolor: str, **kwargs):
|
||||
"""
|
||||
Not needed
|
||||
:param role_node:
|
||||
:param color:
|
||||
:param fontcolor:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def build_block(self, block_node: BlockNode, color: str, fontcolor: str, **kwargs):
|
||||
"""
|
||||
Not needed
|
||||
:param block_node:
|
||||
:param color:
|
||||
:param fontcolor:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
pass
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2023 Mohamed El Mouctar HAIDARA
|
||||
# Copyright (C) 2024 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
|
||||
|
@ -40,13 +40,6 @@ DEFAULT_ORIENTATION = "LR" # Left to right
|
|||
|
||||
|
||||
class MermaidFlowChartRenderer(Renderer):
|
||||
def __init__(
|
||||
self,
|
||||
playbook_nodes: List[PlaybookNode],
|
||||
roles_usage: Dict["RoleNode", Set[PlayNode]],
|
||||
):
|
||||
super().__init__(playbook_nodes, roles_usage)
|
||||
|
||||
def render(
|
||||
self,
|
||||
open_protocol_handler: str,
|
||||
|
|
226
tests/fixtures/json-schemas/v1.json
vendored
Normal file
226
tests/fixtures/json-schemas/v1.json
vendored
Normal file
|
@ -0,0 +1,226 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Ansible Playbook Grapher JSON Output schema (v1)",
|
||||
"description": "The schema definition of the version 1 of the json renderer",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the JSON output",
|
||||
"type": "integer"
|
||||
},
|
||||
"playbooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "Type of the node.",
|
||||
"type": "string",
|
||||
"pattern": "^PlaybookNode$"
|
||||
},
|
||||
"id": {
|
||||
"pattern": "^playbook_.+$",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"when": {
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/$defs/location"
|
||||
},
|
||||
"plays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"pattern": "^PlayNode$",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"pattern": "^play_.+$",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"when": {
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"main": {
|
||||
"type": "string"
|
||||
},
|
||||
"font": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"main",
|
||||
"font"
|
||||
]
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/$defs/location"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
},
|
||||
"pre_tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
},
|
||||
"post_tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"name",
|
||||
"when",
|
||||
"index",
|
||||
"location",
|
||||
"colors",
|
||||
"hosts",
|
||||
"roles",
|
||||
"tasks",
|
||||
"pre_tasks",
|
||||
"post_tasks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"name",
|
||||
"when",
|
||||
"index",
|
||||
"location",
|
||||
"plays"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version",
|
||||
"playbooks"
|
||||
],
|
||||
"$defs": {
|
||||
"task": {
|
||||
"description": "An Ansible task or role",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"pattern": "^(TaskNode|BlockNode|RoleNode)$",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"pattern": "^(pre_task|task|post_task|role|block)_.+$",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name of the task or role",
|
||||
"type": "string"
|
||||
},
|
||||
"when": {
|
||||
"description": "The condition on the task",
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"description": "The index of the task",
|
||||
"type": "integer"
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/$defs/location"
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
},
|
||||
"include_role": {
|
||||
"description": "Flag indicating if this role is an include role or not.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"name",
|
||||
"when",
|
||||
"index",
|
||||
"location"
|
||||
]
|
||||
},
|
||||
"location": {
|
||||
"description": "The node location on the filesystem.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "The type of the location",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"file",
|
||||
"folder"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "Filesystem path",
|
||||
"type": "string"
|
||||
},
|
||||
"line": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"column": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"path",
|
||||
"line",
|
||||
"column"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,4 +2,6 @@
|
|||
pytest==8.3.2
|
||||
pytest-cov==5.0.0
|
||||
pyquery==2.0.1
|
||||
black~=24.8
|
||||
black~=24.8
|
||||
jq==1.8.0
|
||||
jsonschema[format]==4.23.0
|
|
@ -1,8 +1,11 @@
|
|||
import json
|
||||
|
||||
from ansibleplaybookgrapher.graph_model import (
|
||||
RoleNode,
|
||||
TaskNode,
|
||||
PlayNode,
|
||||
BlockNode,
|
||||
PlaybookNode,
|
||||
)
|
||||
|
||||
|
||||
|
@ -99,3 +102,41 @@ def test_has_node_type():
|
|||
assert play.has_node_type(TaskNode), "The play should have a TaskNode"
|
||||
|
||||
assert not role.has_node_type(BlockNode), "The role doesn't have a BlockNode"
|
||||
|
||||
|
||||
def test_to_dict():
|
||||
"""
|
||||
|
||||
:return:
|
||||
"""
|
||||
playbook = PlaybookNode("my-fake-playbook.yml")
|
||||
playbook.add_node("plays", PlayNode("empty"))
|
||||
|
||||
role = RoleNode("my_role")
|
||||
role.add_node("tasks", TaskNode("task 1"))
|
||||
|
||||
block = BlockNode("block 1")
|
||||
block.add_node("tasks", role)
|
||||
|
||||
play = PlayNode("play")
|
||||
play.add_node("tasks", block)
|
||||
play.add_node("post_tasks", TaskNode("task 2"))
|
||||
playbook.add_node("plays", play)
|
||||
|
||||
dict_rep = playbook.to_dict(exclude_empty_plays=True)
|
||||
|
||||
assert dict_rep["type"] == "PlaybookNode"
|
||||
assert dict_rep["location"]["path"] is not None
|
||||
assert dict_rep["location"]["line"] is not None
|
||||
assert dict_rep["location"]["column"] is not None
|
||||
|
||||
assert len(dict_rep["plays"]) == 1
|
||||
assert dict_rep["plays"][0]["type"] == "PlayNode"
|
||||
assert dict_rep["plays"][0]["colors"]["font"] == "#ffffff"
|
||||
|
||||
assert dict_rep["plays"][0]["name"] == "play"
|
||||
assert dict_rep["plays"][0]["tasks"][0]["name"] == "block 1"
|
||||
assert dict_rep["plays"][0]["tasks"][0]["index"] == 1
|
||||
assert dict_rep["plays"][0]["tasks"][0]["type"] == "BlockNode"
|
||||
|
||||
print(json.dumps(dict_rep, indent=4))
|
||||
|
|
|
@ -61,9 +61,7 @@ def run_grapher(
|
|||
# put the generated file in a dedicated folder
|
||||
args.extend(["-o", os.path.join(DIR_PATH, "generated-svgs", output_filename)])
|
||||
|
||||
args.extend(additional_args)
|
||||
|
||||
args.extend(playbook_paths)
|
||||
args.extend(additional_args + playbook_paths)
|
||||
|
||||
cli = PlaybookGrapherCLI(args)
|
||||
|
||||
|
@ -115,7 +113,7 @@ def _common_tests(
|
|||
|
||||
assert (
|
||||
len(playbooks) == playbooks_number
|
||||
), f"The graph '{svg_path}' should contains {playbooks_number} play(s) but we found {len(playbooks)} play(s)"
|
||||
), f"The graph '{svg_path}' should contains {playbooks_number} playbook(s) but we found {len(playbooks)} play(s)"
|
||||
|
||||
assert (
|
||||
len(plays) == plays_number
|
||||
|
@ -147,6 +145,7 @@ def _common_tests(
|
|||
"post_tasks": post_tasks,
|
||||
"pre_tasks": pre_tasks,
|
||||
"roles": roles,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
|
@ -443,7 +442,7 @@ def test_multi_plays(request):
|
|||
)
|
||||
|
||||
|
||||
def test_multiple_playbooks(request):
|
||||
def test_multi_playbooks(request):
|
||||
"""
|
||||
Test with multiple playbooks
|
||||
"""
|
||||
|
|
268
tests/test_json_renderer.py
Normal file
268
tests/test_json_renderer.py
Normal file
|
@ -0,0 +1,268 @@
|
|||
import json
|
||||
import os
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
import jq
|
||||
import pytest
|
||||
from jsonschema import validate
|
||||
from jsonschema.validators import Draft202012Validator
|
||||
|
||||
from ansibleplaybookgrapher import __prog__
|
||||
from ansibleplaybookgrapher.cli import PlaybookGrapherCLI
|
||||
from tests import FIXTURES_DIR
|
||||
|
||||
# This file directory abspath
|
||||
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def run_grapher(
|
||||
playbook_files: List[str],
|
||||
output_filename: str = None,
|
||||
additional_args: List[str] = None,
|
||||
) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Utility function to run the grapher
|
||||
:param playbook_files:
|
||||
:param output_filename:
|
||||
:param additional_args:
|
||||
:return:
|
||||
"""
|
||||
additional_args = additional_args or []
|
||||
# Explicitly add verbosity to the tests
|
||||
additional_args.insert(0, "-vvv")
|
||||
|
||||
if os.environ.get("TEST_VIEW_GENERATED_FILE") == "1":
|
||||
additional_args.insert(0, "--view")
|
||||
|
||||
playbook_paths = [os.path.join(FIXTURES_DIR, p_file) for p_file in playbook_files]
|
||||
args = [__prog__]
|
||||
|
||||
# Clean the name a little bit
|
||||
output_filename = output_filename.replace("[", "-").replace("]", "")
|
||||
# put the generated file in a dedicated folder
|
||||
args.extend(["-o", os.path.join(DIR_PATH, "generated-jsons", output_filename)])
|
||||
|
||||
args.extend(["--renderer", "json"])
|
||||
args.extend(additional_args + playbook_paths)
|
||||
|
||||
cli = PlaybookGrapherCLI(args)
|
||||
|
||||
return cli.run(), playbook_paths
|
||||
|
||||
|
||||
def _common_tests(
|
||||
json_path: str,
|
||||
playbooks_number: int = 1,
|
||||
plays_number: int = 0,
|
||||
tasks_number: int = 0,
|
||||
post_tasks_number: int = 0,
|
||||
roles_number: int = 0,
|
||||
pre_tasks_number: int = 0,
|
||||
blocks_number: int = 0,
|
||||
) -> Dict:
|
||||
"""
|
||||
Do some checks on the generated json files.
|
||||
|
||||
We are using JQ to avoid traversing the JSON ourselves (much easier).
|
||||
:param json_path:
|
||||
:return:
|
||||
"""
|
||||
with open(json_path, "r") as f:
|
||||
output = json.load(f)
|
||||
|
||||
with open(os.path.join(FIXTURES_DIR, "json-schemas/v1.json")) as schema_file:
|
||||
schema = json.load(schema_file)
|
||||
|
||||
# If no exception is raised by validate(), the instance is valid.
|
||||
# I currently don't use format but added it here to not forget to add in case I use in the future.
|
||||
validate(
|
||||
instance=output,
|
||||
schema=schema,
|
||||
format_checker=Draft202012Validator.FORMAT_CHECKER,
|
||||
)
|
||||
|
||||
playbooks = jq.compile(".playbooks[]").input(output).all()
|
||||
|
||||
plays = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "PlayNode" and (.id | startswith("play_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
|
||||
pre_tasks = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "TaskNode" and (.id | startswith("pre_task_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
tasks = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "TaskNode" and (.id | startswith("task_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
post_tasks = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "TaskNode" and (.id | startswith("post_task_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
|
||||
roles = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "RoleNode" and (.id | startswith("role_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
|
||||
blocks = (
|
||||
jq.compile(
|
||||
'.. | objects | select(.type == "BlockNode" and (.id | startswith("block_")))'
|
||||
)
|
||||
.input(output)
|
||||
.all()
|
||||
)
|
||||
|
||||
assert (
|
||||
len(playbooks) == playbooks_number
|
||||
), f"The file '{json_path}' should contains {playbooks_number} playbook(s) but we found {len(playbooks)} playbook(s)"
|
||||
|
||||
assert (
|
||||
len(plays) == plays_number
|
||||
), f"The file '{json_path}' should contains {plays_number} play(s) but we found {len(plays)} play(s)"
|
||||
|
||||
assert (
|
||||
len(pre_tasks) == pre_tasks_number
|
||||
), f"The file '{json_path}' should contains {pre_tasks_number} pre tasks(s) but we found {len(pre_tasks)} pre tasks"
|
||||
|
||||
assert (
|
||||
len(roles) == roles_number
|
||||
), f"The file '{json_path}' should contains {roles_number} role(s) but we found {len(roles)} role(s)"
|
||||
|
||||
assert (
|
||||
len(tasks) == tasks_number
|
||||
), f"The file '{json_path}' should contains {tasks_number} tasks(s) but we found {len(tasks)} tasks"
|
||||
|
||||
assert (
|
||||
len(post_tasks) == post_tasks_number
|
||||
), f"The file '{json_path}' should contains {post_tasks_number} post tasks(s) but we found {len(post_tasks)} post tasks"
|
||||
|
||||
assert (
|
||||
len(blocks) == blocks_number
|
||||
), f"The file '{json_path}' should contains {blocks_number} block(s) but we found {len(blocks)} blocks"
|
||||
|
||||
# Check the play
|
||||
for play in plays:
|
||||
assert (
|
||||
play.get("colors") is not None
|
||||
), f"The play '{play['name']}' is missing colors'"
|
||||
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"plays": plays,
|
||||
"post_tasks": post_tasks,
|
||||
"pre_tasks": pre_tasks,
|
||||
"roles": roles,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
def test_simple_playbook(request):
|
||||
"""
|
||||
|
||||
:return:
|
||||
"""
|
||||
json_path, playbook_paths = run_grapher(
|
||||
["simple_playbook.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=[
|
||||
"-i",
|
||||
os.path.join(FIXTURES_DIR, "inventory"),
|
||||
"--include-role-tasks",
|
||||
],
|
||||
)
|
||||
_common_tests(json_path, plays_number=1, post_tasks_number=2)
|
||||
|
||||
|
||||
def test_with_block(request):
|
||||
"""
|
||||
|
||||
:return:
|
||||
"""
|
||||
json_path, playbook_paths = run_grapher(
|
||||
["with_block.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=[
|
||||
"-i",
|
||||
os.path.join(FIXTURES_DIR, "inventory"),
|
||||
"--include-role-tasks",
|
||||
],
|
||||
)
|
||||
_common_tests(
|
||||
json_path,
|
||||
plays_number=1,
|
||||
pre_tasks_number=4,
|
||||
roles_number=1,
|
||||
tasks_number=7,
|
||||
blocks_number=4,
|
||||
post_tasks_number=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["flag", "roles_number", "tasks_number", "post_tasks_number"],
|
||||
[("--", 6, 9, 8), ("--group-roles-by-name", 6, 9, 8)],
|
||||
ids=["no_group", "group"],
|
||||
)
|
||||
def test_group_roles_by_name(
|
||||
request, flag, roles_number, tasks_number, post_tasks_number
|
||||
):
|
||||
"""
|
||||
Test when grouping roles by name. This doesn't really affect the JSON renderer: multiple nodes will have the same ID.
|
||||
This test ensures that regardless of the flag '--group-roles-by-name', we get the same nodes in the output.
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
json_path, playbook_paths = run_grapher(
|
||||
["group-roles-by-name.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks", flag],
|
||||
)
|
||||
|
||||
_common_tests(
|
||||
json_path,
|
||||
plays_number=1,
|
||||
roles_number=roles_number,
|
||||
tasks_number=tasks_number,
|
||||
post_tasks_number=post_tasks_number,
|
||||
blocks_number=1,
|
||||
)
|
||||
|
||||
|
||||
def test_multi_playbooks(request):
|
||||
"""
|
||||
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
json_path, playbook_paths = run_grapher(
|
||||
["multi-plays.yml", "relative_var_files.yml", "with_roles.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks"],
|
||||
)
|
||||
|
||||
_common_tests(
|
||||
json_path,
|
||||
playbooks_number=3,
|
||||
plays_number=5,
|
||||
pre_tasks_number=4,
|
||||
roles_number=10,
|
||||
tasks_number=35,
|
||||
post_tasks_number=4,
|
||||
)
|
|
@ -49,16 +49,16 @@ def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
|
|||
parser = PlaybookParser(grapher_cli.options.playbook_filenames[0])
|
||||
playbook_node = parser.parse()
|
||||
assert len(playbook_node.plays()) == 1
|
||||
assert playbook_node.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert playbook_node.line == 1
|
||||
assert playbook_node.column == 1
|
||||
assert playbook_node.location.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert playbook_node.location.line == 1
|
||||
assert playbook_node.location.column == 1
|
||||
assert (
|
||||
playbook_node.index is None
|
||||
), "The index of the playbook should be None (it has no parent)"
|
||||
|
||||
play_node = playbook_node.plays()[0]
|
||||
assert play_node.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert play_node.line == 2
|
||||
assert play_node.location.path == os.path.join(FIXTURES_PATH, "example.yml")
|
||||
assert play_node.location.line == 2
|
||||
assert play_node.index == 1
|
||||
|
||||
pre_tasks = play_node.pre_tasks
|
||||
|
@ -99,9 +99,9 @@ def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI):
|
|||
fake_role = play_node.roles[0]
|
||||
assert isinstance(fake_role, RoleNode)
|
||||
assert not fake_role.include_role
|
||||
assert fake_role.path == os.path.join(FIXTURES_PATH, "roles", "fake_role")
|
||||
assert fake_role.line is None
|
||||
assert fake_role.column is None
|
||||
assert fake_role.location.path == os.path.join(FIXTURES_PATH, "roles", "fake_role")
|
||||
assert fake_role.location.line is None
|
||||
assert fake_role.location.column is None
|
||||
assert fake_role.index == 3
|
||||
|
||||
for task_counter, task in enumerate(fake_role.tasks):
|
||||
|
@ -144,8 +144,12 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys):
|
|||
include_role_1 = block_include_role.tasks[0]
|
||||
assert isinstance(include_role_1, RoleNode)
|
||||
assert include_role_1.include_role
|
||||
assert include_role_1.path == os.path.join(FIXTURES_PATH, "include_role.yml")
|
||||
assert include_role_1.line == 10, "The first include role should be at line 9"
|
||||
assert include_role_1.location.path == os.path.join(
|
||||
FIXTURES_PATH, "include_role.yml"
|
||||
)
|
||||
assert (
|
||||
include_role_1.location.line == 10
|
||||
), "The first include role should be at line 9"
|
||||
assert (
|
||||
len(include_role_1.tasks) == 0
|
||||
), "We don't support adding tasks from include_role with loop"
|
||||
|
@ -223,8 +227,8 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
|
|||
assert isinstance(
|
||||
pre_task_block, BlockNode
|
||||
), "The second edge should have a BlockNode as destination"
|
||||
assert pre_task_block.path == os.path.join(FIXTURES_PATH, "with_block.yml")
|
||||
assert pre_task_block.line == 7
|
||||
assert pre_task_block.location.path == os.path.join(FIXTURES_PATH, "with_block.yml")
|
||||
assert pre_task_block.location.line == 7
|
||||
|
||||
# Check tasks
|
||||
task_1 = tasks[0]
|
||||
|
|
Loading…
Add table
Reference in a new issue