Initial support for mermaidjs (#144)

Co-authored-by: haidaraM <haidaraM@users.noreply.github.com>
This commit is contained in:
Mohamed El Mouctar Haidara 2023-05-13 14:41:07 +02:00 committed by GitHub
parent bc318c5814
commit 389e6ffda3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1049 additions and 136 deletions

View file

@ -17,6 +17,7 @@ jobs:
name: Tests Py${{ matrix.python-version }} - Ansible ${{ matrix.ansible-version }}
env:
SVG_FILES_PATH: tests/generated-svgs
MERMAID_FILES_PATH: tests/generated-mermaids
runs-on: ubuntu-latest
strategy:
fail-fast: false
@ -75,12 +76,8 @@ jobs:
# branch: generated-svgs-${{ github.head_ref || github.ref }}-${{ env.MATRIX_JOB_IDENTIFIER }}
# create_branch: true
#
# - name: Publish job summary
# env:
# SVG_COMMIT_SHA: ${{ steps.commit-svg-files.outputs.commit_hash }}
# run: |
# env
# python tests/generate-job-summary.py >> $GITHUB_STEP_SUMMARY
- name: Publish job summary
run: python tests/generate-job-summary.py >> $GITHUB_STEP_SUMMARY
- name: Test installation in virtualenv
run: make test_install ANSIBLE_VERSION=${{ matrix.ansible-version }}

1
.gitignore vendored
View file

@ -109,4 +109,5 @@ venv.bak/
.vagrant
Vagrantfile
generated-svgs
generated-mermaids
**/.DS_Store

View file

@ -1,3 +1,24 @@
# 2.0.0 (unreleased)
## What's Changed
- Add support for MermaidJS. See https://github.com/haidaraM/ansible-playbook-grapher/issues/137
- Update various Dependencies: pytest, pytest-cov, ansible-core, pyquery etc...
- ci: Add dependabot for github-actions
- Rename some tests files
- ...
## Breaking changes
This version contains the following breaking changes. Some of them may likely affect you if you were using the grapher
as a library inside another project:
- Completely refactor the rendering part of the part by making it more extensible in order to support Mermaid.
- Fill the plays, blocks and node with color to make them more visible in the output
- Rename the file `graph.py` to `graph_model.py`
- Use the concatenation of the playbook names as the output filename when graphing multiple playbooks instead of the
first playbook.
# 1.2.0 (2022-08-21)
## What's Changed

View file

@ -23,10 +23,10 @@ test_install: build
@./tests/test_install.sh $(VIRTUALENV_DIR) $(ANSIBLE_VERSION)
test:
cd tests && pytest
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
clean:
@echo "Cleaning..."
rm -rf ansible_playbook_grapher.egg-info build dist $(VIRTUALENV_DIR) tests/htmlcov tests/.pytest_cache .eggs tests/generated-svgs tests/.coverage
rm -rf ansible_playbook_grapher.egg-info build dist $(VIRTUALENV_DIR) tests/htmlcov tests/.pytest_cache .eggs tests/generated-* tests/.coverage
.PHONY: clean test_install

341
README.md
View file

@ -39,7 +39,7 @@ JavaScript:
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.
```shell script
$ sudo apt-get install graphviz # or yum install or brew install
sudo apt-get install graphviz # or yum install or brew install
```
I try to respect [Red Hat Ansible Engine Life Cycle](https://access.redhat.com/support/policy/updates/ansible-engine)
@ -51,6 +51,19 @@ for the supported Ansible version.
pip install ansible-playbook-grapher
```
### Renderers
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...
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**.
If you are interested to support more renderers, feel free to create an issue or raise a PR based on the existing
renderers.
## Usage
```shell
@ -71,6 +84,305 @@ ansible-playbook-grapher tests/fixtures/with_block.yml
![Example](https://raw.githubusercontent.com/haidaraM/ansible-playbook-grapher/main/img/block.png)
```bash
ansible-playbook-grapher --include-role-tasks --renderer mermaid-flowchart tests/fixtures/multi-plays.yml tests/fixtures/with_block.yml
```
```mermaid
---
title: Ansible Playbook Grapher
---
%%{ init: { 'flowchart': { 'curve': 'bumpX' } } }%%
flowchart LR
%% Start of the playbook 'tests/fixtures/multi-plays.yml'
playbook_bd8798bc("tests/fixtures/multi-plays.yml")
%% Start of the play 'Play: all (0)'
play_fb11c1f8["Play: all (0)"]
style play_fb11c1f8 fill:#4d8b41,color:#ffffff
playbook_bd8798bc --> |"1"| play_fb11c1f8
linkStyle 0 stroke:#4d8b41,color:#4d8b41
pre_task_0aea2a6b["[pre_task] Pretask"]
style pre_task_0aea2a6b stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"1"| pre_task_0aea2a6b
linkStyle 1 stroke:#4d8b41,color:#4d8b41
pre_task_e48d82b5["[pre_task] Pretask 2"]
style pre_task_e48d82b5 stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"2"| pre_task_e48d82b5
linkStyle 2 stroke:#4d8b41,color:#4d8b41
%% Start of the role 'fake_role'
role_d95e9d5f("[role] fake_role")
style role_d95e9d5f fill:#4d8b41,color:#ffffff,stroke:#4d8b41
play_fb11c1f8 --> |"3"| role_d95e9d5f
linkStyle 3 stroke:#4d8b41,color:#4d8b41
task_41f6dd12["fake_role : Debug 1"]
style task_41f6dd12 stroke:#4d8b41,fill:#ffffff
role_d95e9d5f --> |"1 [when: ansible_distribution == 'Debian']"| task_41f6dd12
linkStyle 4 stroke:#4d8b41,color:#4d8b41
task_9dcf29d3["fake_role : Debug 2"]
style task_9dcf29d3 stroke:#4d8b41,fill:#ffffff
role_d95e9d5f --> |"2 [when: ansible_distribution == 'Debian']"| task_9dcf29d3
linkStyle 5 stroke:#4d8b41,color:#4d8b41
task_dc3f4611["fake_role : Debug 3 with double quote &#34;here&#34; in the name"]
style task_dc3f4611 stroke:#4d8b41,fill:#ffffff
role_d95e9d5f --> |"3 [when: ansible_distribution == 'Debian']"| task_dc3f4611
linkStyle 6 stroke:#4d8b41,color:#4d8b41
%% End of the role 'fake_role'
%% Start of the role 'display_some_facts'
role_2b74c9f5("[role] display_some_facts")
style role_2b74c9f5 fill:#4d8b41,color:#ffffff,stroke:#4d8b41
play_fb11c1f8 --> |"4"| role_2b74c9f5
linkStyle 7 stroke:#4d8b41,color:#4d8b41
task_1c0691fb["display_some_facts : ansible_architecture"]
style task_1c0691fb stroke:#4d8b41,fill:#ffffff
role_2b74c9f5 --> |"1"| task_1c0691fb
linkStyle 8 stroke:#4d8b41,color:#4d8b41
task_c7d6574e["display_some_facts : ansible_date_time"]
style task_c7d6574e stroke:#4d8b41,fill:#ffffff
role_2b74c9f5 --> |"2"| task_c7d6574e
linkStyle 9 stroke:#4d8b41,color:#4d8b41
task_e26456cf["display_some_facts : Specific included task for Debian"]
style task_e26456cf stroke:#4d8b41,fill:#ffffff
role_2b74c9f5 --> |"3"| task_e26456cf
linkStyle 10 stroke:#4d8b41,color:#4d8b41
%% End of the role 'display_some_facts'
task_f0ec5674["[task] Add backport {{backport}}"]
style task_f0ec5674 stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"5"| task_f0ec5674
linkStyle 11 stroke:#4d8b41,color:#4d8b41
task_614fa7f3["[task] Install packages"]
style task_614fa7f3 stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"6"| task_614fa7f3
linkStyle 12 stroke:#4d8b41,color:#4d8b41
post_task_bfd4a733["[post_task] Posttask"]
style post_task_bfd4a733 stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"7"| post_task_bfd4a733
linkStyle 13 stroke:#4d8b41,color:#4d8b41
post_task_6728f20f["[post_task] Posttask 2"]
style post_task_6728f20f stroke:#4d8b41,fill:#ffffff
play_fb11c1f8 --> |"8"| post_task_6728f20f
linkStyle 14 stroke:#4d8b41,color:#4d8b41
%% End of the play 'Play: all (0)'
%% Start of the play 'Play: database (0)'
play_200a2cda["Play: database (0)"]
style play_200a2cda fill:#195db3,color:#ffffff
playbook_bd8798bc --> |"2"| play_200a2cda
linkStyle 15 stroke:#195db3,color:#195db3
%% Start of the role 'fake_role'
role_584c099b("[role] fake_role")
style role_584c099b fill:#195db3,color:#ffffff,stroke:#195db3
play_200a2cda --> |"1"| role_584c099b
linkStyle 16 stroke:#195db3,color:#195db3
task_4363c0b7["fake_role : Debug 1"]
style task_4363c0b7 stroke:#195db3,fill:#ffffff
role_584c099b --> |"1 [when: ansible_distribution == 'Debian']"| task_4363c0b7
linkStyle 17 stroke:#195db3,color:#195db3
task_aff132aa["fake_role : Debug 2"]
style task_aff132aa stroke:#195db3,fill:#ffffff
role_584c099b --> |"2 [when: ansible_distribution == 'Debian']"| task_aff132aa
linkStyle 18 stroke:#195db3,color:#195db3
task_69120ebe["fake_role : Debug 3 with double quote &#34;here&#34; in the name"]
style task_69120ebe stroke:#195db3,fill:#ffffff
role_584c099b --> |"3 [when: ansible_distribution == 'Debian']"| task_69120ebe
linkStyle 19 stroke:#195db3,color:#195db3
%% End of the role 'fake_role'
%% Start of the role 'display_some_facts'
role_f46ed679("[role] display_some_facts")
style role_f46ed679 fill:#195db3,color:#ffffff,stroke:#195db3
play_200a2cda --> |"2"| role_f46ed679
linkStyle 20 stroke:#195db3,color:#195db3
task_5f4d00e6["display_some_facts : ansible_architecture"]
style task_5f4d00e6 stroke:#195db3,fill:#ffffff
role_f46ed679 --> |"1"| task_5f4d00e6
linkStyle 21 stroke:#195db3,color:#195db3
task_fa40d63b["display_some_facts : ansible_date_time"]
style task_fa40d63b stroke:#195db3,fill:#ffffff
role_f46ed679 --> |"2"| task_fa40d63b
linkStyle 22 stroke:#195db3,color:#195db3
task_3527696f["display_some_facts : Specific included task for Debian"]
style task_3527696f stroke:#195db3,fill:#ffffff
role_f46ed679 --> |"3"| task_3527696f
linkStyle 23 stroke:#195db3,color:#195db3
%% End of the role 'display_some_facts'
%% End of the play 'Play: database (0)'
%% Start of the play 'Play: webserver (0)'
play_34799e65["Play: webserver (0)"]
style play_34799e65 fill:#7f4d73,color:#ffffff
playbook_bd8798bc --> |"3"| play_34799e65
linkStyle 24 stroke:#7f4d73,color:#7f4d73
%% Start of the role 'nested_include_role'
role_1bfe7836("[role] nested_include_role")
style role_1bfe7836 fill:#7f4d73,color:#ffffff,stroke:#7f4d73
play_34799e65 --> |"1"| role_1bfe7836
linkStyle 25 stroke:#7f4d73,color:#7f4d73
task_40d7c36f["nested_include_role : Ensure postgresql is at the latest version"]
style task_40d7c36f stroke:#7f4d73,fill:#ffffff
role_1bfe7836 --> |"1"| task_40d7c36f
linkStyle 26 stroke:#7f4d73,color:#7f4d73
task_bc9f916c["nested_include_role : Ensure that postgresql is started"]
style task_bc9f916c stroke:#7f4d73,fill:#ffffff
role_1bfe7836 --> |"2"| task_bc9f916c
linkStyle 27 stroke:#7f4d73,color:#7f4d73
%% Start of the role 'display_some_facts'
role_dfff70cd("[role] display_some_facts")
style role_dfff70cd fill:#7f4d73,color:#ffffff,stroke:#7f4d73
role_1bfe7836 --> |"3 [when: x is not defined]"| role_dfff70cd
linkStyle 28 stroke:#7f4d73,color:#7f4d73
task_2700f9a8["display_some_facts : ansible_architecture"]
style task_2700f9a8 stroke:#7f4d73,fill:#ffffff
role_dfff70cd --> |"1"| task_2700f9a8
linkStyle 29 stroke:#7f4d73,color:#7f4d73
task_84bd5d7e["display_some_facts : ansible_date_time"]
style task_84bd5d7e stroke:#7f4d73,fill:#ffffff
role_dfff70cd --> |"2"| task_84bd5d7e
linkStyle 30 stroke:#7f4d73,color:#7f4d73
task_1b02d165["display_some_facts : Specific included task for Debian"]
style task_1b02d165 stroke:#7f4d73,fill:#ffffff
role_dfff70cd --> |"3"| task_1b02d165
linkStyle 31 stroke:#7f4d73,color:#7f4d73
%% End of the role 'display_some_facts'
%% Start of the role 'fake_role'
role_6433854b("[role] fake_role")
style role_6433854b fill:#7f4d73,color:#ffffff,stroke:#7f4d73
role_1bfe7836 --> |"4"| role_6433854b
linkStyle 32 stroke:#7f4d73,color:#7f4d73
task_10479304["fake_role : Debug 1"]
style task_10479304 stroke:#7f4d73,fill:#ffffff
role_6433854b --> |"1"| task_10479304
linkStyle 33 stroke:#7f4d73,color:#7f4d73
task_a13ab280["fake_role : Debug 2"]
style task_a13ab280 stroke:#7f4d73,fill:#ffffff
role_6433854b --> |"2"| task_a13ab280
linkStyle 34 stroke:#7f4d73,color:#7f4d73
task_bffa1ed0["fake_role : Debug 3 with double quote &#34;here&#34; in the name"]
style task_bffa1ed0 stroke:#7f4d73,fill:#ffffff
role_6433854b --> |"3"| task_bffa1ed0
linkStyle 35 stroke:#7f4d73,color:#7f4d73
%% End of the role 'fake_role'
%% End of the role 'nested_include_role'
%% Start of the role 'display_some_facts'
role_9188508e("[role] display_some_facts")
style role_9188508e fill:#7f4d73,color:#ffffff,stroke:#7f4d73
play_34799e65 --> |"2"| role_9188508e
linkStyle 36 stroke:#7f4d73,color:#7f4d73
task_b0401984["display_some_facts : ansible_architecture"]
style task_b0401984 stroke:#7f4d73,fill:#ffffff
role_9188508e --> |"1"| task_b0401984
linkStyle 37 stroke:#7f4d73,color:#7f4d73
task_417dd6b4["display_some_facts : ansible_date_time"]
style task_417dd6b4 stroke:#7f4d73,fill:#ffffff
role_9188508e --> |"2"| task_417dd6b4
linkStyle 38 stroke:#7f4d73,color:#7f4d73
task_60860d86["display_some_facts : Specific included task for Debian"]
style task_60860d86 stroke:#7f4d73,fill:#ffffff
role_9188508e --> |"3"| task_60860d86
linkStyle 39 stroke:#7f4d73,color:#7f4d73
%% End of the role 'display_some_facts'
%% End of the play 'Play: webserver (0)'
%% End of the playbook 'tests/fixtures/multi-plays.yml'
%% Start of the playbook 'tests/fixtures/with_block.yml'
playbook_2e263466("tests/fixtures/with_block.yml")
%% Start of the play 'Play: all (0)'
play_74990123["Play: all (0)"]
style play_74990123 fill:#a4288c,color:#ffffff
playbook_2e263466 --> |"1"| play_74990123
linkStyle 40 stroke:#a4288c,color:#a4288c
%% Start of the role 'fake_role'
role_5914bd45("[role] fake_role")
style role_5914bd45 fill:#a4288c,color:#ffffff,stroke:#a4288c
play_74990123 --> |"1"| role_5914bd45
linkStyle 41 stroke:#a4288c,color:#a4288c
pre_task_87f2b15f["fake_role : Debug 1"]
style pre_task_87f2b15f stroke:#a4288c,fill:#ffffff
role_5914bd45 --> |"1"| pre_task_87f2b15f
linkStyle 42 stroke:#a4288c,color:#a4288c
pre_task_64224a5c["fake_role : Debug 2"]
style pre_task_64224a5c stroke:#a4288c,fill:#ffffff
role_5914bd45 --> |"2"| pre_task_64224a5c
linkStyle 43 stroke:#a4288c,color:#a4288c
pre_task_eaf098d3["fake_role : Debug 3 with double quote &#34;here&#34; in the name"]
style pre_task_eaf098d3 stroke:#a4288c,fill:#ffffff
role_5914bd45 --> |"3"| pre_task_eaf098d3
linkStyle 44 stroke:#a4288c,color:#a4288c
%% End of the role 'fake_role'
%% Start of the block 'Block in pre task'
block_59e01f41["[block] Block in pre task"]
style block_59e01f41 fill:#a4288c,color:#ffffff,stroke:#a4288c
play_74990123 --> |"2"| block_59e01f41
linkStyle 45 stroke:#a4288c,color:#a4288c
subgraph subgraph_block_59e01f41["Block in pre task "]
pre_task_d56eed17["debug"]
style pre_task_d56eed17 stroke:#a4288c,fill:#ffffff
block_59e01f41 --> |"1"| pre_task_d56eed17
linkStyle 46 stroke:#a4288c,color:#a4288c
end
%% End of the block 'Block in pre task'
task_13ae5b2d["[task] Install tree"]
style task_13ae5b2d stroke:#a4288c,fill:#ffffff
play_74990123 --> |"3"| task_13ae5b2d
linkStyle 47 stroke:#a4288c,color:#a4288c
%% Start of the block 'Install Apache'
block_58464279["[block] Install Apache"]
style block_58464279 fill:#a4288c,color:#ffffff,stroke:#a4288c
play_74990123 --> |"4 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| block_58464279
linkStyle 48 stroke:#a4288c,color:#a4288c
subgraph subgraph_block_58464279["Install Apache "]
task_3c6e5034["Install some packages"]
style task_3c6e5034 stroke:#a4288c,fill:#ffffff
block_58464279 --> |"1 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| task_3c6e5034
linkStyle 49 stroke:#a4288c,color:#a4288c
task_7f997d4e["template"]
style task_7f997d4e stroke:#a4288c,fill:#ffffff
block_58464279 --> |"2 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| task_7f997d4e
linkStyle 50 stroke:#a4288c,color:#a4288c
%% Start of the block ''
block_58e72e2c["[block] "]
style block_58e72e2c fill:#a4288c,color:#ffffff,stroke:#a4288c
block_58464279 --> |"3 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| block_58e72e2c
linkStyle 51 stroke:#a4288c,color:#a4288c
subgraph subgraph_block_58e72e2c[" "]
task_752a43fc["get_url"]
style task_752a43fc stroke:#a4288c,fill:#ffffff
block_58e72e2c --> |"1 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6') and True]"| task_752a43fc
linkStyle 52 stroke:#a4288c,color:#a4288c
task_b31070fc["command"]
style task_b31070fc stroke:#a4288c,fill:#ffffff
block_58e72e2c --> |"2 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| task_b31070fc
linkStyle 53 stroke:#a4288c,color:#a4288c
end
%% End of the block ''
task_4ff99c46["service"]
style task_4ff99c46 stroke:#a4288c,fill:#ffffff
block_58464279 --> |"4 [when: (ansible_facts['distribution'] == 'CentOS' and ansible_facts['distribution_major_version'] == '6')]"| task_4ff99c46
linkStyle 54 stroke:#a4288c,color:#a4288c
end
%% End of the block 'Install Apache'
task_27e5fe68["[task] Create a username for tomcat"]
style task_27e5fe68 stroke:#a4288c,fill:#ffffff
play_74990123 --> |"5"| task_27e5fe68
linkStyle 55 stroke:#a4288c,color:#a4288c
post_task_b09e48f3["[post_task] Debug"]
style post_task_b09e48f3 stroke:#a4288c,fill:#ffffff
play_74990123 --> |"6"| post_task_b09e48f3
linkStyle 56 stroke:#a4288c,color:#a4288c
%% Start of the block 'My post task block'
block_644a319d["[block] My post task block"]
style block_644a319d fill:#a4288c,color:#ffffff,stroke:#a4288c
play_74990123 --> |"7"| block_644a319d
linkStyle 57 stroke:#a4288c,color:#a4288c
subgraph subgraph_block_644a319d["My post task block "]
post_task_e6f19df5["template"]
style post_task_e6f19df5 stroke:#a4288c,fill:#ffffff
block_644a319d --> |"1"| post_task_e6f19df5
linkStyle 58 stroke:#a4288c,color:#a4288c
end
%% End of the block 'My post task block'
%% End of the play 'Play: all (0)'
%% End of the playbook 'tests/fixtures/with_block.yml'
```
Note on block: Since `block`s are 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)
@ -86,8 +398,12 @@ usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY]
[-o OUTPUT_FILENAME]
[--open-protocol-handler {default,vscode,custom}]
[--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS]
[--group-roles-by-name] [--version] [-t TAGS]
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[--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]
playbooks [playbooks ...]
@ -97,12 +413,13 @@ Make graphs from your Ansible Playbooks.
positional arguments:
playbooks Playbook(s) to graph
optional arguments:
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.
display for all roles having the same names. Default:
False
--include-role-tasks Include the tasks of the role in the graph.
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
The custom formats to use as URLs for the nodes in the
@ -129,6 +446,16 @@ optional arguments:
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
--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" } } }%%'
--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
@ -148,7 +475,7 @@ optional arguments:
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
Output filename without the '.svg' extension. Default:
<playbook>.svg
-s, --save-dot-file Save the dot file used to generate the graph.
-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
@ -195,7 +522,7 @@ Run the tests and open the generated files in your systems default viewer app
```shell script
export TEST_VIEW_GENERATED_FILE=1
$ make test # run all tests
make test # run all tests
```
The graphs are generated in the folder `tests/generated-svgs`. They are also generated as artefacts

View file

@ -1,4 +1,4 @@
# Copyright (C) 2022 Mohamed El Mouctar HAIDARA
# Copyright (C) 2023 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
@ -13,18 +13,18 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Set
from typing import Dict, List, Set, Tuple
from ansible.utils.display import Display
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
PlaybookNode,
RoleNode,
PlayNode,
)
from ansibleplaybookgrapher.parser import PlaybookParser
from ansibleplaybookgrapher.utils import merge_dicts
from .graph import PlaybookNode, PlayNode, TaskNode, RoleNode, BlockNode
from .graph_model import PlaybookNode, PlayNode, TaskNode, RoleNode, BlockNode
from .parser import PlaybookParser
__version__ = "2.0.0-dev"
@ -40,16 +40,13 @@ class Grapher:
"""
self.playbook_filenames = playbook_filenames
# The usage of the roles in all playbooks
self.roles_usage: Dict[RoleNode, Set[PlayNode]] = {}
def parse(
self,
include_role_tasks: bool = False,
tags: List[str] = None,
skip_tags: List[str] = None,
group_roles_by_name: bool = False,
) -> List[PlaybookNode]:
) -> Tuple[List[PlaybookNode], Dict[RoleNode, Set[PlayNode]]]:
"""
Parses all the provided playbooks
:param include_role_tasks: Should we include the role tasks
@ -59,6 +56,8 @@ class Grapher:
:return:
"""
playbook_nodes = []
roles_usage: Dict[RoleNode, Set[PlayNode]] = {}
for playbook_file in self.playbook_filenames:
display.display(f"Parsing playbook {playbook_file}")
playbook_parser = PlaybookParser(
@ -72,8 +71,6 @@ class Grapher:
playbook_nodes.append(playbook_node)
# Update the usage of the roles
self.roles_usage = merge_dicts(
self.roles_usage, playbook_node.roles_usage()
)
roles_usage = merge_dicts(roles_usage, playbook_node.roles_usage())
return playbook_nodes
return playbook_nodes, roles_usage

View file

@ -26,6 +26,11 @@ from ansible.utils.display import Display
from ansibleplaybookgrapher import __prog__, __version__, Grapher
from ansibleplaybookgrapher.renderer import OPEN_PROTOCOL_HANDLERS
from ansibleplaybookgrapher.renderer.graphviz import GraphvizRenderer
from ansibleplaybookgrapher.renderer.mermaid import (
MermaidFlowChartRenderer,
DEFAULT_DIRECTIVE as MERMAID_DEFAULT_DIRECTIVE,
DEFAULT_ORIENTATION as MERMAID_DEFAULT_ORIENTATION,
)
# The display is a singleton. This instruction will NOT return a new instance.
# We explicitly set the verbosity after the init.
@ -52,26 +57,41 @@ class PlaybookGrapherCLI(CLI):
display.verbosity = self.options.verbosity
grapher = Grapher(self.options.playbook_filenames)
playbook_nodes = grapher.parse(
playbook_nodes, roles_usage = 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,
)
# TODO: add condition to choose the renderer
renderer = GraphvizRenderer(
playbook_nodes=playbook_nodes,
roles_usage=grapher.roles_usage,
)
output_path = renderer.render(
open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
save_dot_file=self.options.save_dot_file,
output_filename=self.options.output_filename,
view=self.options.view,
)
return output_path
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,
)
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,
)
return output_path
def _add_my_options(self):
"""
@ -102,7 +122,7 @@ class PlaybookGrapherCLI(CLI):
dest="save_dot_file",
action="store_true",
default=False,
help="Save the dot file used to generate the graph.",
help="Save the graphviz dot file used to generate the graph.",
)
self.parser.add_argument(
@ -155,7 +175,27 @@ 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.",
help="When rendering the graph, only a single role will be display for all roles having the same names. Default: %(default)s",
)
self.parser.add_argument(
"--renderer",
choices=["graphviz", "mermaid-flowchart"],
default="graphviz",
help="The renderer to use to generate the graph. Default: %(default)s",
)
self.parser.add_argument(
"--renderer-mermaid-directive",
default=MERMAID_DEFAULT_DIRECTIVE,
help="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: '%(default)s'",
)
self.parser.add_argument(
"--renderer-mermaid-orientation",
default=MERMAID_DEFAULT_ORIENTATION,
choices=["TD", "RL", "BT", "RL", "LR"],
help="The orientation of the flow chart. Default: '%(default)s'",
)
self.parser.add_argument(
@ -192,10 +232,11 @@ class PlaybookGrapherCLI(CLI):
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]
basenames = map(ntpath.basename, self.options.playbook_filenames)
basenames_without_ext = "-".join(
[os.path.splitext(basename)[0] for basename in basenames]
)
self.options.output_filename = basenames_without_ext
if self.options.open_protocol_handler == "custom":
self.validate_open_protocol_custom_formats()

View file

@ -1,4 +1,4 @@
# Copyright (C) 2022 Mohamed El Mouctar HAIDARA
# Copyright (C) 2023 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,7 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from collections import defaultdict
from typing import Dict, List, Set, Type, Tuple
from typing import Dict, List, Set, Type, Tuple, Optional
from ansibleplaybookgrapher.utils import generate_id, get_play_colors
@ -31,7 +31,7 @@ class Node:
when: str = "",
raw_object=None,
parent: "Node" = None,
index: int = None,
index: Optional[int] = None,
):
"""
@ -53,7 +53,7 @@ class Node:
self.set_position()
# The index of this node in the parent node if it has one (starting from 1)
self.index: int = index
self.index: Optional[int] = index
def set_position(self):
"""

View file

@ -27,7 +27,7 @@ from ansible.playbook.task_include import TaskInclude
from ansible.template import Templar
from ansible.utils.display import Display
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
TaskNode,
PlaybookNode,
RoleNode,
@ -194,7 +194,9 @@ class PlaybookParser(BaseParser):
display.v(f"Parsing {play_name}")
play_node = PlayNode(play_name, hosts=play_hosts, raw_object=play)
play_node = PlayNode(
play_name, hosts=play_hosts, raw_object=play, parent=playbook_root_node
)
playbook_root_node.add_node("plays", play_node)
# loop through the pre_tasks
@ -318,8 +320,7 @@ class PlaybookParser(BaseParser):
for task_or_block in block.block:
if hasattr(task_or_block, "loop") and task_or_block.loop:
display.warning(
"Looping on tasks or roles are not supported for the moment. "
f"Only the task having the loop argument will be added to the graph."
"Looping on tasks or roles are not supported for the moment. Only the task having the loop argument will be added to the graph."
)
if isinstance(task_or_block, Block):

View file

@ -17,7 +17,7 @@ from typing import Dict, Optional, Set
from ansible.utils.display import Display
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
PlaybookNode,
PlayNode,
RoleNode,
@ -41,7 +41,42 @@ OPEN_PROTOCOL_HANDLERS = {
}
class Renderer(ABC):
def __init__(
self,
playbook_nodes: PlaybookNode,
roles_usage: Dict[RoleNode, Set[PlayNode]],
):
self.playbook_nodes = playbook_nodes
self.roles_usage = roles_usage
@abstractmethod
def render(
self,
open_protocol_handler: str,
open_protocol_custom_formats: Dict[str, str],
output_filename: str,
view: bool,
**kwargs,
) -> str:
"""
Render the playbooks to a file.
:param open_protocol_handler: The protocol handler name to use
:param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom
:param output_filename: without any extension
:param view: Whether to open the rendered file in the default viewer
:param kwargs:
:return: The filename of the rendered file
"""
pass
class PlaybookBuilder(ABC):
"""
This the base class to inherit from by the renderer to build a single Playbook in the target format.
It provides some methods that need to be implemented
"""
def __init__(
self,
playbook_node: PlaybookNode,
@ -93,11 +128,11 @@ class PlaybookBuilder(ABC):
)
@abstractmethod
def build_playbook(self, **kwargs):
def build_playbook(self, **kwargs) -> str:
"""
Build the whole playbook
:param kwargs:
:return:
:return: The rendered playbook as a string
"""
pass
@ -111,6 +146,53 @@ class PlaybookBuilder(ABC):
"""
pass
def traverse_play(self, play_node: PlayNode, **kwargs):
"""
Traverse a play to build the graph: pre_tasks, roles, tasks, post_tasks
:param play_node:
:param kwargs:
:return:
"""
color, play_font_color = play_node.colors
# pre_tasks
for pre_task in play_node.pre_tasks:
self.build_node(
node=pre_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[pre_task] ",
**kwargs,
)
# roles
for role in play_node.roles:
self.build_role(
color=color,
fontcolor=play_font_color,
role_node=role,
**kwargs,
)
# tasks
for task in play_node.tasks:
self.build_node(
node=task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[task] ",
**kwargs,
)
# post_tasks
for post_task in play_node.post_tasks:
self.build_node(
node=post_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[post_task] ",
**kwargs,
)
@abstractmethod
def build_task(self, task_node: TaskNode, color: str, fontcolor: str, **kwargs):
"""

View file

@ -18,14 +18,14 @@ from typing import Dict, List, Set
from ansible.utils.display import Display
from graphviz import Digraph
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
PlaybookNode,
PlayNode,
RoleNode,
BlockNode,
TaskNode,
)
from ansibleplaybookgrapher.renderer import PlaybookBuilder
from ansibleplaybookgrapher.renderer import PlaybookBuilder, Renderer
from ansibleplaybookgrapher.renderer.graphviz.postprocessor import GraphvizPostProcessor
display = Display()
@ -39,7 +39,7 @@ DEFAULT_GRAPH_ATTR = {
}
class GraphvizRenderer:
class GraphvizRenderer(Renderer):
def __init__(
self,
playbook_nodes: List[PlaybookNode],
@ -52,7 +52,6 @@ class GraphvizRenderer:
self,
open_protocol_handler: str,
open_protocol_custom_formats: Dict[str, str],
save_dot_file: bool,
output_filename: str,
view: bool,
**kwargs,
@ -60,6 +59,8 @@ class GraphvizRenderer:
"""
:return: The filename where the playbooks where rendered
"""
save_dot_file = kwargs.get("save_dot_file", False)
# Set of the roles that have been built so far for all the playbooks
roles_built = set()
digraph = Digraph(
@ -219,6 +220,13 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
Render a role in the graph
:return:
"""
# check if we already built this role
if role_node in self.roles_built:
return
self.roles_built.add(role_node)
digraph = kwargs["digraph"]
if role_node.include_role: # For include_role, we point to a file
@ -240,12 +248,6 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
labeltooltip=role_edge_label,
)
# check if we already built this role
if role_node in self.roles_built:
return
self.roles_built.add(role_node)
plays_using_this_role = self.roles_usage[role_node]
if len(plays_using_this_role) > 1:
# If the role is used in multiple plays, we take black as the default color
@ -274,10 +276,10 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
digraph=role_subgraph,
)
def build_playbook(self, **kwargs):
def build_playbook(self, **kwargs) -> str:
"""
Convert the PlaybookNode to the graphviz dot format
:return:
:return: The text representation of the graphviz dot format for the playbook
"""
display.vvv(f"Converting the graph to the dot format for graphviz")
# root node
@ -293,6 +295,8 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
with self.digraph.subgraph(name=play.name) as play_subgraph:
self.build_play(play, digraph=play_subgraph, **kwargs)
return self.digraph.source
def build_play(self, play_node: PlayNode, **kwargs):
"""
@ -320,7 +324,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
URL=self.get_node_url(play_node, "file"),
)
# edge from root node to play
# from playbook to play
playbook_to_play_label = f"{play_node.index} {play_node.name}"
self.digraph.edge(
self.playbook_node.id,
@ -333,41 +337,5 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
labeltooltip=playbook_to_play_label,
)
# pre_tasks
for pre_task in play_node.pre_tasks:
self.build_node(
node=pre_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[pre_task] ",
**kwargs,
)
# roles
for role in play_node.roles:
self.build_role(
color=color,
fontcolor=play_font_color,
role_node=role,
**kwargs,
)
# tasks
for task in play_node.tasks:
self.build_node(
node=task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[task] ",
**kwargs,
)
# post_tasks
for post_task in play_node.post_tasks:
self.build_node(
node=post_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[post_task] ",
**kwargs,
)
# traverse the play
self.traverse_play(play_node, **kwargs)

View file

@ -19,7 +19,7 @@ from ansible.utils.display import Display
from lxml import etree
from svg.path import parse_path
from ansibleplaybookgrapher.graph import PlaybookNode
from ansibleplaybookgrapher.graph_model import PlaybookNode
display = Display()

View file

@ -0,0 +1,337 @@
# Copyright (C) 2023 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/>.
from pathlib import Path
from typing import Dict, Set, List
from ansible.utils.display import Display
from ansibleplaybookgrapher import BlockNode, RoleNode, TaskNode, PlayNode, PlaybookNode
from ansibleplaybookgrapher.renderer import PlaybookBuilder, Renderer
display = Display()
# Default directive when rendering the graph.
# More info at
# https://mermaid.js.org/config/directives.html
#
DEFAULT_DIRECTIVE = '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
DEFAULT_ORIENTATION = "LR" # Left to right
class MermaidFlowChartRenderer(Renderer):
def __init__(
self,
playbook_nodes: List[PlaybookNode],
roles_usage: Dict["RoleNode", Set[PlayNode]],
):
self.playbook_nodes = playbook_nodes
self.roles_usage = roles_usage
def render(
self,
open_protocol_handler: str,
open_protocol_custom_formats: Dict[str, str],
output_filename: str,
view: bool,
**kwargs,
) -> str:
"""
:param open_protocol_handler:
:param open_protocol_custom_formats:
:param output_filename: without any extension
:param view:
:param kwargs:
:return:
"""
# TODO: Add support for protocol handler
# TODO: Add support for hover
mermaid_code = "---\n"
mermaid_code += "title: Ansible Playbook Grapher\n"
mermaid_code += "---\n"
directive = kwargs.get("directive", DEFAULT_DIRECTIVE)
orientation = kwargs.get("orientation", DEFAULT_ORIENTATION)
display.vvv(f"Using '{directive}' as directive for the mermaid chart")
mermaid_code += f"{directive}\n"
mermaid_code += f"flowchart {orientation}\n"
# Mermaid only supports adding style to links by using the order of the link when it is created
# https://mermaid.js.org/syntax/flowchart.html#styling-links
link_order = 0
# Set of the roles that have been built so far for all the playbooks
roles_built = set()
for playbook_node in self.playbook_nodes:
playbook_builder = MermaidFlowChartPlaybookBuilder(
playbook_node=playbook_node,
open_protocol_handler=open_protocol_handler,
open_protocol_custom_formats=open_protocol_custom_formats,
roles_usage=self.roles_usage,
roles_built=roles_built,
link_order=link_order,
)
mermaid_code += playbook_builder.build_playbook()
link_order = playbook_builder.link_order
roles_built.update(playbook_builder.roles_built)
final_output_path_file = Path(f"{output_filename}.mmd")
# Make the sure the parents directories exist
final_output_path_file.parent.mkdir(exist_ok=True, parents=True)
final_output_path_file.write_text(mermaid_code)
display.display(
f"Mermaid code written to {final_output_path_file}", color="green"
)
if view:
# TODO: implement the view option
# https://github.com/mermaidjs/mermaid-live-editor/issues/41 and https://mermaid.ink/
display.warning(
"The --view option is not supported yet by the mermaid renderer"
)
return final_output_path_file
class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
def __init__(
self,
playbook_node: PlaybookNode,
open_protocol_handler: str,
open_protocol_custom_formats: Dict[str, str],
roles_usage: Dict[RoleNode, Set[PlayNode]],
roles_built: Set[RoleNode],
link_order: int = 0,
):
super().__init__(
playbook_node,
open_protocol_handler,
open_protocol_custom_formats,
roles_usage,
roles_built,
)
self.mermaid_code = ""
# Used as an identifier for the links
self.link_order = link_order
# The current depth level of the nodes. Used for indentation
self._identation_level = 1
def build_playbook(self, **kwargs) -> str:
"""
Build the playbook
:param kwargs:
:return:
"""
display.vvv(
f"Converting the playbook '{self.playbook_node.name}' to mermaid format"
)
# Playbook node
self.add_comment(f"Start of the playbook '{self.playbook_node.name}'")
self.add_text(f'{self.playbook_node.id}("{self.playbook_node.name}")')
self._identation_level += 1
for play_node in self.playbook_node.plays:
self.build_play(play_node)
self._identation_level -= 1
self.add_comment(f"End of the playbook '{self.playbook_node.name}'\n")
return self.mermaid_code
def build_play(self, play_node: PlayNode, **kwargs):
"""
:param play_node:
:param kwargs:
:return:
"""
# Play node
color, play_font_color = play_node.colors
self.add_comment(f"Start of the play '{play_node.name}'")
self.add_text(f'{play_node.id}["{play_node.name}"]')
self.add_text(f"style {play_node.id} fill:{color},color:{play_font_color}")
# From playbook to play
self.add_link(
source_id=play_node.parent.id,
text=f"{play_node.index}",
dest_id=play_node.id,
style=f"stroke:{color},color:{color}",
)
# traverse the play
self._identation_level += 1
self.traverse_play(play_node)
self._identation_level -= 1
self.add_comment(f"End of the play '{play_node.name}'")
def build_task(self, task_node: TaskNode, color: str, fontcolor: str, **kwargs):
"""
:param task_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
node_label_prefix = kwargs.get("node_label_prefix", "")
# Task node
self.add_text(f'{task_node.id}["{node_label_prefix}{task_node.name}"]')
self.add_text(f"style {task_node.id} stroke:{color},fill:{fontcolor}")
# From parent to task
self.add_link(
source_id=task_node.parent.id,
text=f"{task_node.index} {task_node.when}",
dest_id=task_node.id,
style=f"stroke:{color},color:{color}",
)
def build_role(self, role_node: RoleNode, color: str, fontcolor: str, **kwargs):
"""
:param role_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
# check if we already built this role
if role_node in self.roles_built:
return
self.roles_built.add(role_node)
# Role node
self.add_comment(f"Start of the role '{role_node.name}'")
self.add_text(f'{role_node.id}("[role] {role_node.name}")')
self.add_text(
f"style {role_node.id} fill:{color},color:{fontcolor},stroke:{color}"
)
# from parent to role
self.add_link(
source_id=role_node.parent.id,
text=f"{role_node.index} {role_node.when}",
dest_id=role_node.id,
style=f"stroke:{color},color:{color}",
)
# role tasks
self._identation_level += 1
for role_task in role_node.tasks:
self.build_node(
node=role_task,
color=color,
fontcolor=fontcolor,
)
self._identation_level -= 1
self.add_comment(f"End of the role '{role_node.name}'")
def build_block(self, block_node: BlockNode, color: str, fontcolor: str, **kwargs):
"""
:param block_node:
:param color:
:param fontcolor:
:param kwargs:
:return:
"""
# Block node
self.add_comment(f"Start of the block '{block_node.name}'")
self.add_text(f'{block_node.id}["[block] {block_node.name}"]')
self.add_text(
f"style {block_node.id} fill:{color},color:{fontcolor},stroke:{color}"
)
# from parent to block
self.add_link(
source_id=block_node.parent.id,
text=f"{block_node.index} {block_node.when}",
dest_id=block_node.id,
style=f"stroke:{color},color:{color}",
)
self.add_text(f'subgraph subgraph_{block_node.id}["{block_node.name} "]')
self._identation_level += 1
for task in block_node.tasks:
self.build_node(
node=task,
color=color,
fontcolor=fontcolor,
)
self._identation_level -= 1
self.add_text("end") # End of the subgraph
self.add_comment(f"End of the block '{block_node.name}'")
def add_link(
self,
source_id: str,
text: str,
dest_id: str,
style: str = "",
link_type: str = "--",
):
"""
Add link between two nodes
:param source_id: The link source
:param text: The text on the link
:param dest_id: The link destination
:param style: The style to apply to the link
:param link_type: Type of link to create. https://mermaid.js.org/syntax/flowchart.html#links-between-nodes
:return:
"""
# Replace double quotes with single quotes. Mermaid doesn't like double quotes
text = text.replace('"', "'").strip()
self.add_text(f'{source_id} {link_type}> |"{text}"| {dest_id}')
if style != "" or style is not None:
self.add_text(f"linkStyle {self.link_order} {style}")
self.link_order += 1
def add_comment(self, text: str):
"""
Add a comment to the mermaid code
:param text: The text used as a comment
:return:
"""
self.mermaid_code += f"{self.indentation}%% {text}\n"
def add_text(self, text: str):
"""
Add a text to the mermaid diagram
:param text:
:return:
"""
self.mermaid_code += f"{self.indentation}{text}\n"
@property
def indentation(self) -> str:
"""
Return the current indentation level as tabulations
:return:
"""
return "\t" * self._identation_level

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 278 KiB

View file

@ -7,9 +7,9 @@ from jinja2 import Template
DIR_PATH = os.path.dirname(os.path.realpath(__file__))
def list_svg_files(path_pattern: str) -> List[str]:
def list_files(path_pattern: str) -> List[str]:
"""
Return the list of files matching the pattern
:param path_pattern:
:return:
"""
@ -20,11 +20,10 @@ if __name__ == "__main__":
with open(os.path.join(DIR_PATH, "job-summary.md.j2")) as template_file:
template = Template(template_file.read())
svg_files = list_svg_files(f"{os.environ['SVG_FILES_PATH']}/*.svg")
links = []
for f in svg_files:
links.append(
f"https://raw.githubusercontent.com/{os.environ['GITHUB_REPOSITORY']}/{os.environ['SVG_COMMIT_SHA']}/{f}"
)
mermaid_files = list_files(f"{os.environ['MERMAID_FILES_PATH']}/*.mmd")
matrix_job_identifier = os.environ["MATRIX_JOB_IDENTIFIER"]
files = []
for filename in mermaid_files:
files.append({"name": filename, "content": open(filename).read()})
print(template.render(svg_files=links))
print(template.render(files=files, matrix_job_identifier=matrix_job_identifier))

View file

@ -1,6 +1,14 @@
### Generated SVG files
| Images |
|:-------:|
{% for svg_file in svg_files -%}
| ![svg]({{ svg_file }}) |
## Generated Mermaid files
This is the list of all the mermaid graph generated by the tests.
**Note:** It doesn't match the generated SVGs graphs yet in the artifacts.
{% for file in files -%}
### {{ file.name }} - {{ matrix_job_identifier }}
```mermaid
{{ file.content }}
```
{% endfor %}

View file

@ -85,6 +85,20 @@ def test_cli_output_filename(output_filename_option, expected):
assert cli.options.output_filename == expected
def test_cli_output_filename_multiple_playbooks():
"""
Test for the output filename when using multiple playbooks
:return:
"""
args = [__prog__] + ["playbook.yml", "second-playbook.yml", "third-playbook.yaml"]
cli = PlaybookGrapherCLI(args)
cli.parse()
assert cli.options.output_filename == "playbook-second-playbook-third-playbook"
@pytest.mark.parametrize(
"include_role_tasks_option, expected",
[(["--"], False), (["--include-role-tasks"], True)],

View file

@ -1,4 +1,4 @@
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
RoleNode,
TaskNode,
PlayNode,

View file

@ -3,7 +3,7 @@ from _elementtree import Element
import pytest
from lxml import etree
from ansibleplaybookgrapher.graph import PlaybookNode, PlayNode, TaskNode
from ansibleplaybookgrapher.graph_model import PlaybookNode, PlayNode, TaskNode
from ansibleplaybookgrapher.renderer.graphviz.postprocessor import (
GraphvizPostProcessor,
SVG_NAMESPACE,

View file

@ -16,7 +16,7 @@ DIR_PATH = os.path.dirname(os.path.realpath(__file__))
def run_grapher(
playbook_files: List[str],
output_filename: str = None,
output_filename: str,
additional_args: List[str] = None,
) -> Tuple[str, List[str]]:
"""
@ -24,7 +24,7 @@ def run_grapher(
:param output_filename:
:param additional_args:
:param playbook_files:
:return: SVG path and playbook absolute path
:return: SVG path and playbooks absolute paths
"""
additional_args = additional_args or []
# Explicitly add verbosity to the tests
@ -56,10 +56,10 @@ def run_grapher(
playbook_paths = [os.path.join(FIXTURES_DIR, p_file) for p_file in playbook_files]
args = [__prog__]
if output_filename: # the default filename is the playbook file name minus .yml
# put the generated svg in a dedicated folder
output_filename = output_filename.replace("[", "-").replace("]", "")
args.extend(["-o", os.path.join(DIR_PATH, "generated-svgs", output_filename)])
# 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-svgs", output_filename)])
args.extend(additional_args)
@ -94,12 +94,12 @@ def _common_tests(
:return: A dictionary with the different tasks, roles, pre_tasks as keys and a list of Elements (nodes) as values
"""
pq = PyQuery(filename=svg_path)
pq.remove_namespaces()
# test if the file exist. It will exist only if we write in it.
assert os.path.isfile(svg_path), "The svg file should exist"
pq = PyQuery(filename=svg_path)
pq.remove_namespaces()
playbooks = pq("g[id^='playbook_']")
plays = pq("g[id^='play_']")
tasks = pq("g[id^='task_']")

View file

@ -0,0 +1,120 @@
import os
from typing import List, Tuple
import pytest
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 output_filename:
:param additional_args:
:param playbook_files:
:return: Mermaid file path and playbooks absolute paths
"""
additional_args = additional_args or []
# Explicitly add verbosity to the tests
additional_args.insert(0, "-vvv")
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("]", "").replace(".yml", "")
)
# put the generated file in a dedicated folder
args.extend(["-o", os.path.join(DIR_PATH, "generated-mermaids", output_filename)])
args.extend(additional_args)
args.extend(["--renderer", "mermaid-flowchart"])
args.extend(playbook_paths)
cli = PlaybookGrapherCLI(args)
return cli.run(), playbook_paths
def _common_tests(mermaid_path: str, playbook_paths: List[str], **kwargs):
"""
:param mermaid_path:
:param playbook_paths:
:param kwargs:
:return:
"""
# TODO: add proper tests on the mermaid code.
# Need a parser to make sure the outputs contain all the playbooks, plays, tasks and roles
# test if the file exist. It will exist only if we write in it.
assert os.path.isfile(
mermaid_path
), f"The mermaid file should exist at '{mermaid_path}'"
@pytest.mark.parametrize(
"playbook_file",
[
# FIXME: Once we have proper tests, we need to split the parameters similar to what we do with graphviz
"docker-mysql-galaxy.yml",
"example.yml",
"group-roles-by-name.yml",
"import_playbook.yml",
"import_role.yml",
"import_tasks.yml",
"include_role.yml",
"include_tasks.yml",
"multi-plays.yml",
"nested_import_playbook.yml",
"nested_include_tasks.yml",
"relative_var_files.yml",
"roles_dependencies.yml",
"simple_playbook.yml",
"tags.yml",
"with_block.yml",
"with_roles.yml",
],
)
def test_playbook(request, playbook_file):
"""
Test the renderer with a single playbook
"""
mermaid_path, playbook_paths = run_grapher(
[playbook_file],
output_filename=request.node.name,
additional_args=[
"-i",
os.path.join(FIXTURES_DIR, "inventory"),
"--include-role-tasks",
],
)
_common_tests(mermaid_path, playbook_paths)
def test_multiple_playbooks(request):
"""
Test the renderer with multiple playbooks in a single graph
"""
mermaid_path, playbook_paths = run_grapher(
["multi-plays.yml", "relative_var_files.yml", "with_roles.yml"],
output_filename=request.node.name,
additional_args=[
"-i",
os.path.join(FIXTURES_DIR, "inventory"),
"--include-role-tasks",
],
)
_common_tests(mermaid_path, playbook_paths)

View file

@ -6,7 +6,7 @@ from ansible.utils.display import Display
from ansibleplaybookgrapher import PlaybookParser
from ansibleplaybookgrapher.cli import PlaybookGrapherCLI
from ansibleplaybookgrapher.graph import (
from ansibleplaybookgrapher.graph_model import (
TaskNode,
BlockNode,
RoleNode,