├── Gemfile ├── .rspec ├── example.png ├── sample ├── roles │ ├── role1 │ │ ├── meta │ │ │ └── main.yml │ │ ├── defaults │ │ │ └── main.yml │ │ ├── vars │ │ │ ├── extra.yml │ │ │ ├── main.yml │ │ │ └── maininc.yml │ │ └── tasks │ │ │ ├── task2.yml │ │ │ ├── task1.yml │ │ │ └── main.yml │ └── roleA │ │ ├── defaults │ │ └── main.yml │ │ ├── vars │ │ ├── extra.yml │ │ ├── main.yml │ │ └── maininc.yml │ │ └── tasks │ │ ├── taskB.yml │ │ ├── taskA.yml │ │ └── main.yml ├── playbookA.yml ├── playbook1.yml └── README.txt ├── .travis.yml ├── bin └── ansible-viz.rb ├── Rakefile ├── lib └── ansible_viz │ ├── utils.rb │ ├── legend.rb │ ├── loader.rb │ ├── styler.rb │ ├── cli.rb │ ├── varfinder.rb │ ├── postprocessor.rb │ ├── grapher.rb │ ├── graphviz.rb │ ├── resolver.rb │ └── scoper.rb ├── test ├── test_resolver.rb ├── test_helper.rb ├── test_grapher.rb ├── test_varfinder.rb ├── test_scoper.rb ├── test_loader.rb └── test_postprocessor.rb ├── CONTRIBUTING.md ├── .gitignore ├── Guardfile ├── diagram.mustache ├── ansible-viz.gemspec ├── README.md ├── spec ├── loader_spec.rb └── spec_helper.rb ├── Gemfile.lock └── LICENSE /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format documentation 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aspiers/ansible-viz/HEAD/example.png -------------------------------------------------------------------------------- /sample/roles/role1/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - role: roleA 4 | -------------------------------------------------------------------------------- /sample/roles/role1/defaults/main.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | def1: "def1" 4 | -------------------------------------------------------------------------------- /sample/roles/roleA/defaults/main.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | defA: "defA" 4 | -------------------------------------------------------------------------------- /sample/roles/role1/vars/extra.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | var1extra: "var1extra" 4 | -------------------------------------------------------------------------------- /sample/roles/role1/vars/main.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | var1main: "var1main" 4 | -------------------------------------------------------------------------------- /sample/roles/roleA/vars/extra.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | varAextra: "varAextra" 4 | -------------------------------------------------------------------------------- /sample/roles/roleA/vars/main.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | varAmain: "varAmain" 4 | -------------------------------------------------------------------------------- /sample/roles/role1/tasks/task2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: part1 | Role 1 task2 part1 3 | set_fact: 4 | fact2: "omg" 5 | -------------------------------------------------------------------------------- /sample/roles/role1/vars/maininc.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | var1maininc: "var1maininc" 4 | -------------------------------------------------------------------------------- /sample/roles/roleA/tasks/taskB.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: partA | Role A taskB partA 3 | set_fact: 4 | factB: "omg" 5 | -------------------------------------------------------------------------------- /sample/roles/roleA/vars/maininc.yml: -------------------------------------------------------------------------------- 1 | # Variables for the vagrant environment 2 | --- 3 | varAmaininc: "varAmaininc" 4 | -------------------------------------------------------------------------------- /sample/playbookA.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: build 3 | sudo: yes 4 | roles: 5 | - roleA 6 | tasks: 7 | - include: roles/roleA/tasks/taskA.yml service=nova 8 | tags: 9 | - main 10 | - build 11 | -------------------------------------------------------------------------------- /sample/roles/roleA/tasks/taskA.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include_vars: extra.yml 3 | 4 | - name: partA | Role A taskA partA 5 | set_fact: 6 | factAunused: "{{ defA | factB | 7 | update(varAmain | default({})) | 8 | update(varAextra | default({})) }}" 9 | -------------------------------------------------------------------------------- /sample/playbook1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: build 3 | sudo: yes 4 | roles: 5 | - roleA 6 | - role1 7 | tasks: 8 | - include: roles/roleA/tasks/taskA.yml service=nova 9 | - include: roles/role1/tasks/task1.yml service=nova 10 | tags: 11 | - main 12 | - build 13 | 14 | - include: playbookA.yml 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | rvm: 4 | - 2.2.9 5 | - 2.3.5 6 | - 2.4.2 7 | - 2.5.0 8 | env: 9 | - TEST_TASK=spec 10 | - TEST_TASK=minitest 11 | 12 | script: | 13 | bundle exec rake $TEST_TASK 14 | # bundle exec codeclimate-test-reporter 15 | 16 | cache: bundler 17 | 18 | #addons: 19 | # code_climate: 20 | # repo_token: ... 21 | -------------------------------------------------------------------------------- /sample/roles/roleA/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include_vars: maininc.yml 3 | 4 | - include: taskB.yml meow=AAA 5 | 6 | - name: main | Role A main 7 | set_fact: 8 | factAmain: "{{ defA | 9 | update(varAmain | default({})) | 10 | update(varAmaininc | default({})) }}" 11 | 12 | - name: main | Role A main 13 | blah: "{{ factAmain | varAundef }}" 14 | -------------------------------------------------------------------------------- /bin/ansible-viz.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require "ansible_viz/cli" 4 | 5 | def main 6 | options = get_options() 7 | 8 | divider "Loading" 9 | data = Loader.new.load_dir(options.playbook_dir) 10 | graph = graph_from_data(data, options) 11 | 12 | divider "Rendering graph" 13 | write(graph, options.output_filename) 14 | end 15 | 16 | if __FILE__ == $0 17 | main 18 | end 19 | -------------------------------------------------------------------------------- /sample/roles/role1/tasks/task1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include_vars: extra.yml 3 | 4 | - name: partA | Role A taskA partA 5 | doobry: "{{ defA | factB | 6 | update(varAmain | default({})) | 7 | update(varAextra | default({})) }}" 8 | 9 | - name: part1 | Role 1 task1 part1 10 | set_fact: 11 | fact1unused: "{{ def1 | fact2 | 12 | update(var1main | default({})) | 13 | update(var1extra | default({})) }}" 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require "rake/testtask" 3 | require 'rspec/core/rake_task' 4 | 5 | $LOAD_PATH.unshift File.dirname(__FILE__) 6 | 7 | task default: :test 8 | task test: [:minitest, :spec] 9 | 10 | RSpec::Core::RakeTask.new(:spec) 11 | 12 | desc "Run minitest tests" 13 | Rake::TestTask.new do |t| 14 | t.name = :minitest 15 | t.test_files = FileList['test/**/test_*.rb'] 16 | t.verbose = false 17 | end 18 | -------------------------------------------------------------------------------- /sample/roles/role1/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include_vars: maininc.yml 3 | 4 | - include: task2.yml meow=111 5 | 6 | - name: part1 | Role 1 main 7 | blah: "{{ factAmain | defA | 8 | update(varAmain | default({})) | 9 | update(varAmaininc | default({})) }}" 10 | 11 | - name: part2 | Role A main 12 | blah: "{{ factAmain | varAundef }}" 13 | 14 | - name: part3 | Role 1 main 15 | set_fact: 16 | fact1main: "{{ def1 | 17 | update(var1main | default({})) | 18 | update(var1maininc | default({})) }}" 19 | 20 | - name: part4 | Role 1 main 21 | blah: "{{ fact1main | var1undef }}" 22 | -------------------------------------------------------------------------------- /lib/ansible_viz/utils.rb: -------------------------------------------------------------------------------- 1 | # FIXME: evil evil global, get rid of this! 2 | $debug_level = 1 3 | 4 | def debug(level, msg) 5 | $stderr.puts msg if $debug_level >= level 6 | end 7 | 8 | def tty_width 9 | ENV['COLUMNS'] ? ENV['COLUMNS'].to_i : 78 10 | end 11 | 12 | def wrap_indent(indent, list) 13 | list.join(" ") \ 14 | .wrap(tty_width - indent.size) \ 15 | .gsub(/^/, indent) 16 | end 17 | 18 | def default_options 19 | OpenStruct.new( 20 | format: :hot, 21 | output_filename: "viz.html", 22 | show_tasks: true, 23 | show_varfiles: true, 24 | show_templates: true, 25 | show_vars: false, 26 | show_vardefaults: true, 27 | show_usage: true, 28 | ) 29 | end 30 | -------------------------------------------------------------------------------- /test/test_resolver.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'minitest/autorun' 4 | 5 | require_relative 'test_helper' 6 | require 'ansible_viz/resolver' 7 | 8 | class TC_ResolverA < Minitest::Test 9 | def setup 10 | skip 11 | @d = {} 12 | end 13 | 14 | def test_role_deps 15 | # assert_has_all %w(), @roleA[:role_deps].smap(:name) 16 | end 17 | 18 | def test_task_includes 19 | end 20 | 21 | def test_task_include_vars 22 | end 23 | end 24 | 25 | 26 | class TC_Resolver1 < Minitest::Test 27 | def setup 28 | skip 29 | @d = {} 30 | end 31 | 32 | def test_role_deps 33 | # assert_has_all %w(), @roleA[:role_deps].smap(:name) 34 | end 35 | 36 | def test_task_includes 37 | end 38 | 39 | def test_task_include_vars 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `ansible-viz` 2 | 3 | ## Issue tracking 4 | 5 | Any kind of feedback is very welcome; please first check that your bug 6 | / issue / enhancement request is not already listed here: 7 | 8 | * https://github.com/aspiers/ansible-viz/issues 9 | 10 | and if not then file a new issue. 11 | 12 | ## Helping with development 13 | 14 | Any [pull request](https://help.github.com/articles/using-pull-requests/) 15 | providing an enhancement or bugfix is extremely welcome! 16 | 17 | However my spare time to work on this project is very limited, so 18 | please follow these 19 | [guidelines on contributing](http://blog.adamspiers.org/2012/11/10/7-principles-for-contributing-patches-to-software-projects/) so that you can help me to help you ;-) 20 | 21 | Thanks in advance! 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Specific to this repo 2 | *.html 3 | *.sh 4 | 5 | 6 | ## General 7 | *.gem 8 | *.rbc 9 | /.config 10 | /coverage/ 11 | /InstalledFiles 12 | /pkg/ 13 | /spec/reports/ 14 | /test/tmp/ 15 | /test/version_tmp/ 16 | /tmp/ 17 | 18 | ## Specific to RubyMotion: 19 | .dat* 20 | .repl_history 21 | build/ 22 | 23 | ## Documentation cache and generated files: 24 | /.yardoc/ 25 | /_yardoc/ 26 | /doc/ 27 | /rdoc/ 28 | 29 | ## Environment normalisation: 30 | /.bundle/ 31 | /lib/bundler/man/ 32 | 33 | # for a library or gem, you might want to ignore these files since the code is 34 | # intended to run in multiple environments; otherwise, check them in: 35 | # Gemfile.lock 36 | # .ruby-version 37 | # .ruby-gemset 38 | 39 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 40 | .rvmrc 41 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | directories %w(test spec) \ 2 | .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")} 3 | 4 | guard :rspec, cmd: "bundle exec rspec", all_on_start: false do 5 | require "guard/rspec/dsl" 6 | dsl = Guard::RSpec::Dsl.new(self) 7 | 8 | # RSpec files 9 | rspec = dsl.rspec 10 | watch(rspec.spec_helper) { rspec.spec_dir } 11 | watch(rspec.spec_support) { rspec.spec_dir } 12 | watch(rspec.spec_files) 13 | 14 | # Ruby files 15 | ruby = dsl.ruby 16 | dsl.watch_spec_files_for(ruby.lib_files) 17 | end 18 | 19 | guard :minitest, all_on_start: false do 20 | # with Minitest::Unit 21 | watch(%r{^test/(.*)\/?test_(.*)\.rb$}) 22 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" } 23 | watch(%r{^test/test_helper\.rb$}) { 'test' } 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'minitest/reporters' 4 | Minitest::Reporters.use! [Minitest::Reporters::ProgressReporter.new(:color => true)] 5 | require 'minitest/autorun' 6 | 7 | require 'ansible_viz/utils' 8 | $debug_level = 0 9 | 10 | def assert_has_all(e, a, m="") 11 | if m != "" 12 | m += ": " 13 | end 14 | missing = e - a 15 | extra = a - e 16 | assert_equal [[], []], [missing, extra], "#{m}missing/extra items" 17 | end 18 | 19 | def assert_keys(it, *keys) 20 | base = [:type, :name, :fqn, :path] 21 | assert_has_all base + keys, it.keys 22 | end 23 | 24 | module Enumerable 25 | def smap(sym) 26 | map {|i| i[sym] } 27 | end 28 | def flat_smap(sym) 29 | flat_map {|i| i[sym] } 30 | end 31 | end 32 | 33 | require 'simplecov' 34 | SimpleCov.start do 35 | add_filter "/test" 36 | end 37 | -------------------------------------------------------------------------------- /diagram.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 12 | 13 | 14 |
15 |
16 |

Powered by viz.js

17 |

Generated at {{{now}}}

18 |
19 | 20 | 23 | 24 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /sample/README.txt: -------------------------------------------------------------------------------- 1 | RoleA is designed to have at least one of everything. 2 | 3 | roleA/defaults/main.yml 4 | Sets defA. 5 | roleA/vars/main.yml 6 | Sets varAmain. 7 | roleA/vars/maininc.yml 8 | Sets varAmaininc. 9 | roleA/vars/extra.yml 10 | Sets varAextra. 11 | roleA/tasks/main 12 | Includes vars from maininc.yml 13 | Includes taskB, setting meow=AAA. 14 | Sets + uses factAmain. 15 | Uses defA, varAmain, varAmaininc, varAundef (UNDEFINED). 16 | roleA/tasks/taskA 17 | Includes vars from extra.yml 18 | Sets factAunused (UNUSED). 19 | Uses defA, varAmain, varAextra, factB. 20 | roleA/tasks/taskB 21 | Sets factB. 22 | 23 | 24 | Playbook A just uses stuff from role A. 25 | Uses roleA. 26 | Uses taskA. 27 | 28 | 29 | Role1 is a copy of roleA, but also: 30 | Includes roleA via meta/main.yml. 31 | Everything with an A or B in it has a 1 or 2 instead. 32 | All tasks use both versions of the vars/facts the roleA versions do. 33 | Task1 does NOT directly include taskB, only task2. 34 | 35 | 36 | Playbook 1: 37 | Uses roleA and role1. 38 | Uses taskA and task1. 39 | 40 | 41 | 42 | TODO: 43 | Test variable precedence, IE vars that override each other. 44 | -------------------------------------------------------------------------------- /ansible-viz.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | #require File.expand_path("../lib/guard/sclang/version", __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "ansible-viz" 6 | s.author = "Alexis Lee, Adam Spiers" 7 | s.email = "ansible@adamspiers.org" 8 | s.summary = "Guard gem for visualising Ansible playbooks" 9 | s.homepage = "http://github.com/aspiers/ansible-viz" 10 | s.license = "Apache 2.0" 11 | s.version = "0.1.0" # FIXME: Ansible::Viz::VERSION 12 | 13 | s.description = <<-DESC 14 | ansible-viz generates web-based visualisations of the relationships between 15 | components within Ansible playbooks, using Graphviz. 16 | DESC 17 | 18 | s.add_dependency "rake" 19 | s.add_dependency "mustache", "~> 0.99.4" 20 | s.add_dependency "word_wrap" 21 | s.add_development_dependency "pry" 22 | s.add_development_dependency "rspec", "~> 3.7" 23 | s.add_development_dependency "minitest", "~> 5.0" 24 | s.add_development_dependency "minitest-reporters" 25 | s.add_development_dependency "simplecov" 26 | s.add_development_dependency "guard" 27 | s.add_development_dependency "guard-rspec" 28 | s.add_development_dependency "guard-minitest" 29 | s.add_development_dependency "libnotify" 30 | 31 | s.bindir = "bin" 32 | s.files = %w(README.md LICENSE) 33 | s.files += %w(diagram.mustache viz.js) 34 | s.files += Dir["*.rb"] 35 | end 36 | -------------------------------------------------------------------------------- /test/test_grapher.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'minitest/autorun' 4 | require 'ostruct' 5 | 6 | require_relative 'test_helper' 7 | require 'ansible_viz/grapher' 8 | require 'ansible_viz/cli' 9 | 10 | class TC_Grapher < Minitest::Test 11 | def setup 12 | @g = Graph.new 13 | end 14 | 15 | def w 16 | # write(@g, "test.html") 17 | end 18 | 19 | def test_full 20 | options = default_options 21 | options.playbook_dir = "/path/to/playbook" 22 | @g = graph_from_data(Loader.new.load_dir("sample"), options) 23 | assert ! @g.nil? 24 | w 25 | end 26 | 27 | def test_add_node 28 | d = {} 29 | role = thing(d, :role, "role", "rolepath") 30 | var = thing(role, :var, "var", "varpath") 31 | Grapher.new.add_node(@g, var) 32 | assert_equal 1, @g.nodes.length 33 | @g.nodes.each {|n| assert_equal "role::var", n[:label] } 34 | end 35 | 36 | def test_add_nodes 37 | d = {} 38 | role = thing(d, :role, "rrr", "rolepath") 39 | task = thing(role, :task, "ttt", "taskpath") 40 | thing(task, :var, "fff", "fff/varpath") # fact 41 | varfile = thing(role, :varfile, "sss", "sss/varpath") 42 | thing(varfile, :var, "vvv", "vvv/varpath") 43 | role[:vardefaults] = [] 44 | role[:template] = [] 45 | thing(d, :playbook, "ppp", "playbookpath", 46 | {:role => [role], :task => [task]}) 47 | 48 | styler = Styler.new 49 | Grapher.new.add_nodes(@g, d, styler, true) 50 | 51 | assert_equal 6, @g.nodes.length 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_varfinder.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'minitest/autorun' 5 | require 'ostruct' 6 | 7 | require_relative 'test_helper' 8 | require 'ansible_viz/loader' 9 | require 'ansible_viz/postprocessor' 10 | require 'ansible_viz/scoper' 11 | require 'ansible_viz/varfinder' 12 | 13 | 14 | class TC_FindVars < Minitest::Test 15 | def try(expect, input) 16 | role = thing({}, :role, "role", "rolepath") 17 | task = thing(role, :task, "task1", "taskpath") 18 | assert_equal expect, VarFinder.new.find_vars_in_task(task, input) 19 | end 20 | 21 | def test_str 22 | try %w(def), "abc {{def}} ghi" 23 | end 24 | 25 | def test_list 26 | try %w(a b), ["{{a}}", "{{b}}"] 27 | end 28 | 29 | def test_hash 30 | try %w(a b), {:a => "{{a}}", :b => "{{b}}"} 31 | end 32 | 33 | def test_nesting 34 | try %w(a b c), {:a => ["{{a}}", "{{b}}"], :b => "{{c}}"} 35 | end 36 | 37 | def test_bar 38 | try %w(a b), "{{a|up(b)}}" 39 | end 40 | 41 | def test_stdout 42 | try [], "{{a.stdout}}" 43 | end 44 | 45 | def test_complex 46 | try %w(a b), "{{a | up(b | default({}))}}" 47 | end 48 | 49 | def test_array 50 | try %w(con), "{{ con['aaa-bbb']['ccc'] }}" 51 | end 52 | end 53 | 54 | 55 | class TC_VarFinder < Minitest::Test 56 | def setup 57 | @d = Loader.new.load_dir("sample") 58 | @role1 = @d[:role].find {|r| r[:name] == "role1" } 59 | @roleA = @d[:role].find {|r| r[:name] == "roleA" } 60 | Postprocessor.new.process(@d) 61 | Resolver.new.process(@d) 62 | VarFinder.new.process(@d) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/aspiers/ansible-viz.svg?branch=master)](https://travis-ci.org/aspiers/ansible-viz) 2 | 3 | ansible-viz 4 | =========== 5 | 6 | GraphViz depiction of Ansible dependencies. 7 | 8 | Run: 9 | 10 | gem install bundler 11 | bundle install 12 | bundle exec ruby bin/ansible-viz.rb 13 | 14 | Now browse `viz.html` or `with-vars.html`. The diagram is drawn 15 | client-side with [`viz.js`](https://github.com/mdaines/viz.js/). 16 | 17 | There are probably still a few bugs, particularly around var usage tracking. 18 | 19 | See [`sample/README.txt`](sample/README.txt) for details on test 20 | data. Run 21 | 22 | bundle exec rake test 23 | 24 | to execute tests and generate a coverage report. The tests create a 25 | graph of the sample data in `test.html`. 26 | 27 | ## Example 28 | 29 | ![](example.png) 30 | 31 | ## History 32 | 33 | This tool was [originally written](https://github.com/lxsli/ansible-viz) 34 | by [Alexis Lee](https://github.com/lxsli/ansible-viz), who kindly 35 | [agreed to transfer maintainership over](https://github.com/lxsli/ansible-viz/issues/3) 36 | so that the project could be revived. 37 | 38 | ## Similar projects 39 | 40 | - [ARA](https://github.com/openstack/ara) is an awesome tool, but it 41 | doesn't generate graphs. It also relies on run-time analysis, which 42 | has both pros and cons vs. static analysis. 43 | 44 | - [ansigenome](https://github.com/nickjj/ansigenome) has lots of cool 45 | things rather than specialising on graphing. The current maintainer 46 | actually tried it before trying ansible-viz (let alone before 47 | accidentally becoming maintainer), but it didn't meet his graphing 48 | needs at the time. Still potentially worth looking at though. 49 | 50 | - [ansible-roles-graph](https://github.com/sebn/ansible-roles-graph) 51 | is similar but much simpler and is written in Python. 52 | -------------------------------------------------------------------------------- /test/test_scoper.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'minitest/autorun' 5 | require 'ostruct' 6 | 7 | require_relative "test_helper" 8 | require "ansible_viz/loader" 9 | require "ansible_viz/postprocessor" 10 | require "ansible_viz/varfinder" 11 | require "ansible_viz/scoper" 12 | require "ansible_viz/resolver" 13 | 14 | 15 | class TC_Scoper < Minitest::Test 16 | def setup 17 | @d = Loader.new.load_dir("sample") 18 | @role1 = @d[:role].find {|r| r[:name] == "role1" } 19 | @roleA = @d[:role].find {|r| r[:name] == "roleA" } 20 | Postprocessor.new(default_options).process(@d) 21 | Resolver.new.process(@d) 22 | VarFinder.new.process(@d) 23 | @s = Scoper.new 24 | end 25 | 26 | def test_order_tasks 27 | skip("FIXME: broken test or code") 28 | expect = %w(taskB main taskA task2 main task1) 29 | assert_equal expect, @s.order_tasks([@role1, @roleA]).smap(:name) 30 | assert_equal expect, @s.order_tasks([@roleA, @role1]).smap(:name) 31 | end 32 | 33 | def test_scope 34 | skip("FIXME: broken test or code") 35 | @s.process(@d) 36 | mainApre = %w(defA varAmain factB meow) 37 | mainA = mainApre + %w(varAmaininc factAmain) 38 | main1pre = mainA + %w(def1 var1main fact2) 39 | main1 = main1pre + %w(var1maininc fact1main) 40 | scopes = [[@roleA, "taskB", mainApre + %w(factB)], 41 | [@roleA, "main", mainA], 42 | [@roleA, "taskA", mainA + %w(varAextra factB factAunused service)], 43 | [@role1, "task2", main1pre + %w(fact2)], 44 | [@role1, "main", main1], 45 | [@role1, "task1", main1 + %w(var1extra fact2 fact1unused service)]] 46 | scopes.each {|role, tn, scope| 47 | task = role[:task].find {|t| t[:name] == tn } 48 | assert_not_nil task 49 | assert_not_nil task[:scope], "#{role[:name]} #{tn}" 50 | assert_has_all scope, task[:scope].smap(:name), "#{role[:name]} #{tn}" 51 | } 52 | end 53 | 54 | def test_var_usage 55 | @s.process(@d) 56 | 57 | # task_by_name = Hash[*(task[:scope].flat_map {|v| [v[:name], v] })] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/test_loader.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'minitest' 4 | require 'minitest/autorun' 5 | 6 | require_relative 'test_helper' 7 | require 'ansible_viz/loader' 8 | 9 | class TC_Loader < Minitest::Test 10 | def test_thing 11 | d = {} 12 | 13 | it = thing(d, :abc, "def", "path", {"ghi" => "jkl"}) 14 | it2 = thing(it, :xyz, "456", "path2") 15 | 16 | thing1 = {:type=>:abc, :name=>"def", :fqn=>"def", "ghi"=>"jkl", 17 | :path=>"path", :xyz=>[it2]} 18 | assert_equal(thing1, it) 19 | 20 | thing2 = {:type=>:xyz, :name=>"456", :fqn=>"def::456", 21 | :path=>"path2", :parent=>it} 22 | assert_equal(thing2, it2) 23 | assert_has_all d[:abc], [it] 24 | end 25 | 26 | def test_ls_yml 27 | assert_has_all %w(playbook1.yml playbookA.yml), Loader.ls_yml("sample") 28 | begin 29 | Loader.ls_yml("none") 30 | flunk 31 | rescue RuntimeError 32 | end 33 | assert_equal [], Loader.ls_yml("none", []) 34 | end 35 | 36 | def test_load_dir 37 | d = Loader.new.load_dir("sample") 38 | [:playbook, :role].each {|i| assert d.keys.include?(i), "missing #{i}" } 39 | end 40 | 41 | def test_role 42 | d = {} 43 | role = Loader.new.mk_role(d, "sample/roles", "role1") 44 | 45 | assert_has_all %w(maininc extra main), role[:varfile].smap(:name) 46 | role.delete(:varfile) 47 | 48 | assert_has_all %w(main), role[:vardefaults].smap(:name) 49 | role.delete(:vardefaults) 50 | 51 | assert_has_all %w(main task1 task2), role[:task].smap(:name) 52 | role.delete(:task) 53 | 54 | expect = thing({}, :role, "role1", "sample/roles/role1", 55 | {:role_deps => ["roleA"]}) 56 | assert_equal expect, role 57 | end 58 | 59 | def test_mk_child 60 | d = {} 61 | role = thing(d, :role, "role", "rolepath") 62 | varfile = Loader.new.load_thing(role, :varfile, "sample/roles/role1/vars", "main.yml") 63 | 64 | varfile.delete :data 65 | expected_role = thing({}, :role, "role", "rolepath") 66 | expected_varfile = thing(expected_role, :varfile, "main", 67 | "sample/roles/role1/vars/main.yml", 68 | {:parent => d}) 69 | assert_equal expected_varfile, varfile 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/loader_spec.rb: -------------------------------------------------------------------------------- 1 | # This seems to be required for guard-rspec to work; presumably it 2 | # ignores .rspec in some rspec runs. 3 | require "spec_helper" 4 | 5 | require "ansible_viz/loader" 6 | 7 | RSpec.describe Loader do 8 | it "test thing" do 9 | d = {} 10 | 11 | it = thing(d, :abc, "def", "path", {"ghi" => "jkl"}) 12 | it2 = thing(it, :xyz, "456", "path2") 13 | 14 | thing1 = {:type=>:abc, :name=>"def", :fqn=>"def", "ghi"=>"jkl", 15 | :path=>"path", :xyz=>[it2]} 16 | expect(thing1).to eq(it) 17 | 18 | thing2 = {:type=>:xyz, :name=>"456", :fqn=>"def::456", 19 | :path=>"path2", :parent=>it} 20 | expect(thing2).to eq(it2) 21 | expect(d[:abc]).to eq([it]) 22 | end 23 | context "yml loader" do 24 | it "should load the playbook yml files" do 25 | expect(Loader.ls_yml("sample")).to contain_exactly("playbook1.yml", "playbookA.yml") 26 | end 27 | it "should not load any yml if there are none" do 28 | expect(Loader.ls_yml("none", {})).to eq([]) 29 | end 30 | end 31 | 32 | context "dir loader" do 33 | it "should have playbook and roles" do 34 | expect(Loader.new.load_dir("sample")).to include(:playbook, :role) 35 | end 36 | end 37 | 38 | context "role loader" do 39 | before(:each) do 40 | @role = Loader.new.mk_role({}, "sample/roles", "role1") 41 | end 42 | it "has all varfiles loaded" do 43 | expect(@role[:varfile].map{ |v| v[:name] }).to contain_exactly("main", "extra", "maininc") 44 | end 45 | it "has all vardefaults loaded" do 46 | expect(@role[:vardefaults].map{ |v| v[:name] }).to contain_exactly("main") 47 | end 48 | it "has all tasks loaded" do 49 | expect(@role[:task].map{ |v| v[:name] }).to contain_exactly("main", "task1", "task2") 50 | end 51 | it "has the proper dependencies" do 52 | expect(@role[:role_deps]).to contain_exactly("roleA") 53 | end 54 | it "has the proper name" do 55 | expect(@role[:name]).to eq("role1") 56 | end 57 | it "has the proper path" do 58 | expect(@role[:path]).to eq("sample/roles/role1") 59 | end 60 | end 61 | 62 | context "thing loader" do 63 | it "should load vars" do 64 | role = thing({}, :role, "role", "rolepath") 65 | varfile = Loader.new.load_thing(role, :varfile, "sample/roles/role1/vars", "main.yml") 66 | varfile.delete :data 67 | expected_varfile = thing( 68 | role, :varfile, "main","sample/roles/role1/vars/main.yml", {:parent => {}} 69 | ) 70 | expect(expected_varfile).to eq(varfile) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ansible-viz (0.1.0) 5 | mustache (~> 0.99.4) 6 | rake 7 | word_wrap 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | ansi (1.5.0) 13 | builder (3.2.3) 14 | coderay (1.1.2) 15 | diff-lcs (1.3) 16 | docile (1.1.5) 17 | ffi (1.9.21) 18 | formatador (0.2.5) 19 | guard (2.14.2) 20 | formatador (>= 0.2.4) 21 | listen (>= 2.7, < 4.0) 22 | lumberjack (>= 1.0.12, < 2.0) 23 | nenv (~> 0.1) 24 | notiffany (~> 0.0) 25 | pry (>= 0.9.12) 26 | shellany (~> 0.0) 27 | thor (>= 0.18.1) 28 | guard-compat (1.2.1) 29 | guard-minitest (2.4.6) 30 | guard-compat (~> 1.2) 31 | minitest (>= 3.0) 32 | guard-rspec (4.7.3) 33 | guard (~> 2.1) 34 | guard-compat (~> 1.1) 35 | rspec (>= 2.99.0, < 4.0) 36 | json (2.1.0) 37 | libnotify (0.9.4) 38 | ffi (>= 1.0.11) 39 | listen (3.1.5) 40 | rb-fsevent (~> 0.9, >= 0.9.4) 41 | rb-inotify (~> 0.9, >= 0.9.7) 42 | ruby_dep (~> 1.2) 43 | lumberjack (1.0.12) 44 | method_source (0.9.0) 45 | minitest (5.11.3) 46 | minitest-reporters (1.1.19) 47 | ansi 48 | builder 49 | minitest (>= 5.0) 50 | ruby-progressbar 51 | mustache (0.99.8) 52 | nenv (0.3.0) 53 | notiffany (0.1.1) 54 | nenv (~> 0.1) 55 | shellany (~> 0.0) 56 | pry (0.11.3) 57 | coderay (~> 1.1.0) 58 | method_source (~> 0.9.0) 59 | rake (12.3.0) 60 | rb-fsevent (0.10.2) 61 | rb-inotify (0.9.10) 62 | ffi (>= 0.5.0, < 2) 63 | rspec (3.7.0) 64 | rspec-core (~> 3.7.0) 65 | rspec-expectations (~> 3.7.0) 66 | rspec-mocks (~> 3.7.0) 67 | rspec-core (3.7.1) 68 | rspec-support (~> 3.7.0) 69 | rspec-expectations (3.7.0) 70 | diff-lcs (>= 1.2.0, < 2.0) 71 | rspec-support (~> 3.7.0) 72 | rspec-mocks (3.7.0) 73 | diff-lcs (>= 1.2.0, < 2.0) 74 | rspec-support (~> 3.7.0) 75 | rspec-support (3.7.1) 76 | ruby-progressbar (1.9.0) 77 | ruby_dep (1.5.0) 78 | shellany (0.0.1) 79 | simplecov (0.15.1) 80 | docile (~> 1.1.0) 81 | json (>= 1.8, < 3) 82 | simplecov-html (~> 0.10.0) 83 | simplecov-html (0.10.2) 84 | thor (0.20.0) 85 | word_wrap (1.0.0) 86 | 87 | PLATFORMS 88 | ruby 89 | 90 | DEPENDENCIES 91 | ansible-viz! 92 | guard 93 | guard-minitest 94 | guard-rspec 95 | libnotify 96 | minitest (~> 5.0) 97 | minitest-reporters 98 | pry 99 | rspec (~> 3.7) 100 | simplecov 101 | 102 | BUNDLED WITH 103 | 1.16.1 104 | -------------------------------------------------------------------------------- /lib/ansible_viz/legend.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'ansible_viz/graphviz' 5 | require 'ansible_viz/styler' 6 | require 'pp' 7 | 8 | 9 | class Legend 10 | def mk_legend(options) 11 | styler = Styler.new 12 | types = [:playbook, :role, :task, :varfile, :vardefaults, :var, :template] 13 | nodes = Hash[*(types.flat_map {|type| 14 | node = styler.style(GNode[type.to_s.capitalize], type) 15 | [type, node] 16 | })] 17 | nodes[:varfile][:label] = "Extra vars file" 18 | nodes[:vardefaults][:label] = "Extra defaults file" 19 | nodes[:main_var] = styler.style(GNode["Var"], :var) 20 | nodes[:main_default] = styler.style(GNode["Default"], :var_default) 21 | nodes[:default_var] = styler.style(GNode["Default"], :var_default) 22 | nodes[:unused] = styler.style(GNode["Unused var"], :var_unused) 23 | nodes[:undefined] = styler.style(GNode["Undefined var"], :var_undefined) 24 | nodes[:fact] = styler.style(GNode["Fact / Argument"], :var_fact) 25 | 26 | %w(task vardefaults varfile var template).each do |type| 27 | option = "show_%ss" % type.gsub(/s$/, '') 28 | nodes.delete type.to_sym if ! options.send option 29 | end 30 | 31 | edges = [ 32 | GEdge[nodes[:playbook], nodes[:role], {:label => "calls"}], 33 | styler.style(GEdge[nodes[:playbook], nodes[:playbook], {:label => "include"}], 34 | :include_playbook), 35 | styler.style(GEdge[nodes[:playbook], nodes[:task], {:label => "calls task"}], 36 | :call_task), 37 | styler.style(GEdge[nodes[:role], nodes[:role], {:label => "includes"}], :includes_role), 38 | GEdge[nodes[:role], nodes[:task], {:label => "calls"}], 39 | GEdge[nodes[:role], nodes[:varfile], {:label => "provides"}], 40 | GEdge[nodes[:role], nodes[:main_var], {:label => "main vars define"}], 41 | GEdge[nodes[:role], nodes[:main_default], {:label => "main defaults define"}], 42 | GEdge[nodes[:role], nodes[:vardefaults], {:label => "provides"}], 43 | GEdge[nodes[:role], nodes[:template], {:label => "provides"}], 44 | GEdge[nodes[:vardefaults], nodes[:default_var], {:label => "defines"}], 45 | GEdge[nodes[:varfile], nodes[:var], {:label => "defines"}], 46 | GEdge[nodes[:varfile], nodes[:unused], {:label => "defines"}], 47 | styler.style(GEdge[nodes[:task], nodes[:task], {:label => "includes"}], :includes_task), 48 | styler.style(GEdge[nodes[:task], nodes[:undefined], {:label => "uses"}], :use_var), 49 | GEdge[nodes[:task], nodes[:fact], {:label => "defines"}], 50 | GEdge[nodes[:task], nodes[:vardefaults], {:label => "include_vars"}], 51 | GEdge[nodes[:task], nodes[:default_var], {:label => "uses"}], 52 | GEdge[nodes[:task], nodes[:template], {:label => "applies"}], 53 | GEdge[nodes[:template], nodes[:var], {:label => "uses"}], 54 | ] 55 | 56 | edges.reject! {|e| e.snode.nil? || e.dnode.nil? } 57 | 58 | edges.flat_map {|e| 59 | n = GNode[e[:label]] 60 | n[:shape] = 'none' 61 | 62 | e1 = GEdge[e.snode, n] 63 | e2 = GEdge[n, e.dnode] 64 | [e1, e2].each {|ee| 65 | ee.attrs = e.attrs.dup 66 | ee[:label] = nil 67 | } 68 | e1[:arrowhead] = 'none' 69 | [e1, e2].each {|ee| 70 | ee[:weight] ||= 1 71 | } 72 | 73 | [e1, e2] 74 | } 75 | legend = Graph.new_cluster('legend') 76 | legend.add(*edges) 77 | legend[:bgcolor] = Styler.hsl(15, 3, 100) 78 | legend[:label] = "Legend" 79 | legend[:fontsize] = 36 80 | legend 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ansible_viz/loader.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'mustache' 6 | require 'yaml' 7 | require 'fileutils' 8 | require 'ostruct' 9 | require 'pp' 10 | 11 | require 'ansible_viz/utils' 12 | 13 | def thing(parent, type, name, path, extra = {}) 14 | human_path = path.sub(/^#{ENV['HOME']}/, '~') 15 | debug 2, "Loading new #{type} '#{name}' from #{human_path}, parent '#{parent[:name]}'" 16 | it = {:type => type, :name => name, :fqn => name, :path => path}.merge(extra) 17 | if parent[:type] != nil 18 | it.merge!({:parent => parent, 19 | :fqn => "#{parent[:fqn]}::#{name}"}) 20 | end 21 | parent[type] ||= [] 22 | parent[type].push it 23 | it 24 | end 25 | 26 | 27 | class Loader 28 | # Creates things for playbooks, roles, tasks and varfiles. 29 | # Vars in role/defaults/main.yml are provided as a varfile with type :vardefaults. 30 | # Includes are noted by name/path, not turned into thing refs. 31 | 32 | class < "vars", 84 | :vardefaults => "defaults", 85 | :task => "tasks", 86 | }.each_pair {|type, dirname| 87 | debug 3, " loading #{type} for role '#{name}'" 88 | dir = File.join(role_path, dirname) 89 | Loader.ls_yml(dir, []).map {|f| 90 | load_thing(role, type, dir, f) } 91 | } 92 | 93 | dir = File.join(role_path, "templates") 94 | mk_templates(dict, role, dir) 95 | 96 | role 97 | end 98 | 99 | def mk_templates(dict, role, template_dir, subdir=nil) 100 | path = subdir ? File.join(template_dir, subdir) : template_dir 101 | Loader.ls(path, []).map {|f| 102 | name = File.basename(f, '.*') 103 | dirent = File.join(path, f) 104 | if File.directory? dirent 105 | mk_templates(dict, role, template_dir, 106 | subdir ? File.join(subdir, dirent) : f) 107 | end 108 | next unless File.file? dirent 109 | data = File.readlines(dirent) 110 | thing(role, :template, 111 | subdir ? File.join(subdir, name) : name, 112 | dirent, {:data => data}) 113 | } 114 | end 115 | 116 | def load_thing(parent, type, dir, file) 117 | name = File.basename(file, '.*') 118 | path = File.join(dir, file) 119 | data = Loader.yaml_slurp(path) || {} 120 | thing(parent, type, name, path, {:data => data}) 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/ansible_viz/styler.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'ansible_viz/graphviz' 6 | require 'pp' 7 | 8 | 9 | class Styler 10 | class < { 24 | :shape => 'folder', 25 | :style => 'filled', 26 | :fillcolor => hsl(13, 40, 100)}, 27 | :role => { 28 | :shape => 'house', 29 | :style => 'filled', 30 | :fillcolor => hsl(76, 45, 100)}, 31 | :task => { 32 | :shape => 'octagon', 33 | :style => 'filled', 34 | :fillcolor => hsl(66, 45, 100)}, 35 | :varfile => { 36 | :shape => 'box3d', 37 | :style => 'filled', 38 | :fillcolor => hsl(33, 90, 100)}, 39 | :vardefaults => { 40 | :shape => 'box3d', 41 | :style => 'filled', 42 | :fillcolor => hsl(33, 60, 80)}, 43 | :var => { 44 | :shape => 'oval', 45 | :style => 'filled', 46 | :fillcolor => hsl(33, 60, 95)}, 47 | :var_default => { 48 | :style => 'filled', 49 | :fillcolor => hsl(33, 60, 80)}, 50 | :var_fact => { 51 | :style => 'filled', 52 | :fillcolor => hsl(33, 80, 100)}, 53 | :template => { 54 | :shape => 'note', 55 | :style => 'filled', 56 | :fillcolor => hsl(44, 65, 90)}, 57 | 58 | # Node decorations 59 | :var_unused => {:style => 'filled', 60 | :fillcolor => hsl(88, 50, 100), 61 | :fontcolor => hsl(0, 0, 0)}, 62 | :var_undefined => {:style => 'filled', 63 | :fillcolor => hsl(88, 100, 100)}, 64 | 65 | # Edge styles 66 | :use_var => {:color => hsl(0, 0, 85), 67 | :tooltip => 'uses var'}, 68 | :applies_template => {:color => hsl(44, 65, 90)}, 69 | # :penwidth => 2, :style => 'dashed'}, 70 | :call_task => {:color => 'blue', :penwidth => 2, :style => 'dashed'}, 71 | :include => {:color => hsl(33, 100, 40), :penwidth => 2, :style => 'dashed'}, 72 | :include_playbook => :include, 73 | :includes_role => :include, 74 | :includes_task => :include, 75 | :private => {:color => 'red', :penwidth => 2, :style => 'dashed'}, 76 | } 77 | end 78 | 79 | def style(node_or_edge, style) 80 | while style.is_a?(Symbol) 81 | style = @style[style] || {} 82 | end 83 | style.each_pair {|k,v| node_or_edge[k] = v } 84 | node_or_edge 85 | end 86 | 87 | def decorate(g, dict, options) 88 | g.nodes.each {|node| 89 | data = node.data 90 | type = data[:type] 91 | style(node, type) 92 | node[:label] = data[:name] 93 | typename = case type 94 | when :varfile then "Vars" 95 | else type.to_s.capitalize 96 | end 97 | path = data[:path].sub %r!^#{Regexp.quote(options.playbook_dir)}/!, "" 98 | node[:tooltip] = "#{typename} #{data[:fqn]} from #{path}" 99 | 100 | case type 101 | when :var 102 | if data[:used].nil? 103 | debug 2, "WARNING: var #{node.data[:fqn]} missing :used" 104 | end 105 | if ! data[:used] || data[:used].length == 0 106 | style(node, :var_unused) 107 | node[:tooltip] += '- UNUSED' 108 | elsif not data[:defined] 109 | style(node, :var_undefined) 110 | node[:tooltip] += '- UNDEFINED' 111 | elsif data[:parent][:type] == :vardefaults 112 | style(node, :var_default) 113 | elsif data[:parent][:type] == :task 114 | style(node, :var_fact) 115 | end 116 | end 117 | } 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/ansible_viz/cli.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'bundler/setup' 6 | 7 | require 'mustache' 8 | require 'yaml' 9 | require 'fileutils' 10 | require 'optparse' 11 | require 'ostruct' 12 | require 'pp' 13 | 14 | require 'ansible_viz/graphviz' 15 | require 'ansible_viz/loader' 16 | require 'ansible_viz/postprocessor' 17 | require 'ansible_viz/resolver' 18 | require 'ansible_viz/varfinder' 19 | require 'ansible_viz/scoper' 20 | require 'ansible_viz/grapher' 21 | require 'ansible_viz/legend' 22 | require 'ansible_viz/utils' 23 | 24 | def get_options() 25 | options = default_options 26 | 27 | OptionParser.new do |o| 28 | o.banner = "Usage: ansible-viz.rb [options] " 29 | o.on("-o", "--output [FILE]", "Where to write output") do |fname| 30 | options.output_filename = fname 31 | end 32 | o.on("--[no-]tasks", 33 | "Include tasks.") do |val| 34 | options.show_tasks = val 35 | end 36 | o.on("--[no-]templates", 37 | "Include templates.") do |val| 38 | options.show_templates = val 39 | end 40 | o.on("--[no-]vardefaults", 41 | "Include variable defaults.") do |val| 42 | options.show_vardefaults = val 43 | end 44 | o.on("--[no-]main-defaults", 45 | "Include main defaults.") do |val| 46 | options.show_main_defaults = val 47 | end 48 | o.on("--[no-]varfiles", 49 | "Include variable files.") do |val| 50 | options.show_varfiles = val 51 | end 52 | o.on("--[no-]vars", 53 | "Include vars. Unused/undefined detection still has minor bugs.") do |val| 54 | options.show_vars = val 55 | end 56 | o.on("-eREGEXP", "--exclude-nodes=REGEXP", 57 | "Regexp of nodes to exclude from the graph, " \ 58 | "e.g. 'role:myrole[1-3]|task:mytask[4-6]'") do |regex| 59 | options.exclude_nodes = Regexp.new(regex) 60 | end 61 | o.on("-EREGEXP", "--exclude-edges=REGEXP", 62 | "Regexp of edges to exclude from the graph, " \ 63 | "e.g. 'role:myrole[1-3] -> task:mytask[4-6]'") do |regex| 64 | options.exclude_edges = Regexp.new(regex) 65 | end 66 | o.on("--no-usage", 67 | "Don't connect vars to where they're used.") do |val| 68 | options.show_usage = false 69 | end 70 | o.on("-v[LEVEL]", "--verbose=[LEVEL]", 71 | "Show debugging") do |level| 72 | $debug_level = level ? level.to_i : 2 73 | end 74 | o.on_tail("-h", "--help", "Show this message") do 75 | puts o 76 | exit 77 | end 78 | end.parse! 79 | 80 | if ARGV.length != 1 81 | abort("Must provide the path to your playbooks") 82 | end 83 | options.playbook_dir = ARGV.shift 84 | 85 | options 86 | end 87 | 88 | def divider(section) 89 | debug 2, "=" * tty_width 90 | debug 1, section + " ..." 91 | debug 2, "" 92 | end 93 | 94 | def graph_from_data(data, options) 95 | divider "Postprocessing" 96 | Postprocessor.new(options).process(data) 97 | 98 | divider "Resolving" 99 | Resolver.new.process(data) 100 | 101 | divider "Finding variables" 102 | VarFinder.new.process(data) 103 | 104 | divider "Scoping variables" 105 | Scoper.new.process(data) 106 | 107 | divider "Building graph" 108 | build_graph(data, options) 109 | end 110 | 111 | def build_graph(data, options) 112 | grapher = Grapher.new 113 | g = grapher.graph(data, options) 114 | g[:rankdir] = 'LR' 115 | g.is_cluster = true 116 | 117 | # unlinked = grapher.extract_unlinked(g) 118 | legend = Legend.new.mk_legend(options) 119 | 120 | superg = Graph.new 121 | # superg.add g, unlinked, legend 122 | superg.add g, legend 123 | superg[:rankdir] = 'LR' 124 | superg[:ranksep] = 2 125 | superg[:tooltip] = ' ' 126 | superg 127 | end 128 | 129 | def write(graph, filename) 130 | Mustache.template_file = 'diagram.mustache' 131 | view = Mustache.new 132 | view[:now] = Time.now.strftime("%Y.%m.%d %H:%M:%S") 133 | 134 | view[:title] = "Ansible dependencies" 135 | view[:dotdata] = g2dot(graph) 136 | 137 | path = filename 138 | File.open(path, 'w') do |f| 139 | f.puts view.render 140 | end 141 | puts "Wrote #{path}" 142 | end 143 | -------------------------------------------------------------------------------- /lib/ansible_viz/varfinder.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'pp' 6 | 7 | 8 | class VarFinder 9 | # Vars can appear: 10 | # * In role[:varfile][:var], when defined in vars/ 11 | # * In role[:task][:var], when defined by set_fact 12 | class < 0 then pp with_dict end 135 | 136 | _when = [data['when']].flat_map {|s| 137 | find_vars_in_string("{{ #{s} }}") 138 | } 139 | # if _when.length > 0 then pp _when end 140 | 141 | items + with_dict + _when 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/test_postprocessor.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'minitest/autorun' 4 | 5 | require_relative 'test_helper' 6 | require 'ansible_viz/loader' 7 | require 'ansible_viz/postprocessor' 8 | 9 | ROLE_KEYS = :role_deps, :task, :main_task, :varfile, :vardefaults, :template 10 | TASK_KEYS = :data, :parent, :included_tasks, :included_varfiles, :var, :args, :included_by_tasks, :used_templates 11 | 12 | class TC_PostprocessorA < Minitest::Test 13 | def setup 14 | @d = Loader.new.load_dir("sample") 15 | Postprocessor.new(default_options).process(@d) 16 | @roleA = @d[:role].find {|r| r[:name] == 'roleA' } 17 | @main = @roleA[:task].find {|t| t[:name] == 'main' } 18 | @taskA = @roleA[:task].find {|t| t[:name] == 'taskA' } 19 | end 20 | 21 | def test_role 22 | assert_keys @roleA, *ROLE_KEYS 23 | assert_has_all %w(), @roleA[:role_deps].smap(:name) 24 | assert_has_all %w(main taskA taskB), @roleA[:task].smap(:name) 25 | assert_has_all %w(main maininc extra), @roleA[:varfile].smap(:name) 26 | assert_has_all %w(main), @roleA[:vardefaults].smap(:name) 27 | end 28 | 29 | def test_main 30 | assert_keys @main, *TASK_KEYS 31 | assert_equal @roleA, @main[:parent] 32 | assert_has_all [["taskB.yml", ["meow"]]], @main[:included_tasks] 33 | assert_has_all %w(maininc.yml), @main[:included_varfiles] 34 | assert_has_all %w(factAmain), @main[:var].smap(:name) 35 | assert_has_all %w(), @main[:args] 36 | assert_has_all %w(), @main[:included_by_tasks] 37 | end 38 | 39 | def test_task 40 | assert_keys @taskA, *TASK_KEYS 41 | assert_equal @roleA, @taskA[:parent] 42 | assert_has_all %w(), @taskA[:included_tasks] 43 | assert_has_all %w(extra.yml), @taskA[:included_varfiles] 44 | assert_has_all %w(factAunused), @taskA[:var].smap(:name) 45 | assert_has_all %w(service), @taskA[:args] 46 | assert_has_all %w(), @taskA[:included_by_tasks] 47 | end 48 | 49 | def test_vars 50 | varfile = @roleA[:varfile].find {|vf| vf[:name] == 'extra' } 51 | 52 | assert_keys varfile, :data, :parent, :var 53 | assert_has_all %w(varAextra), varfile[:var].smap(:name) 54 | varfile[:var].each {|var| 55 | assert !var[:used] 56 | assert var[:defined] 57 | } 58 | end 59 | 60 | def test_playbookA 61 | playbookA = @d[:playbook].find {|pb| pb[:name] == 'playbookA' } 62 | 63 | assert_keys playbookA, :data, :include, :role, :task 64 | assert_has_all [], playbookA[:include] 65 | assert_has_all [@roleA], playbookA[:role] 66 | assert_has_all %w(taskA), playbookA[:task].smap(:name) 67 | end 68 | end 69 | 70 | 71 | class TC_Postprocessor1 < Minitest::Test 72 | def setup 73 | @d = Loader.new.load_dir("sample") 74 | Postprocessor.new(default_options).process(@d) 75 | @roleA = @d[:role].find {|r| r[:name] == 'roleA' } 76 | @role1 = @d[:role].find {|r| r[:name] == 'role1' } 77 | @main = @role1[:task].find {|t| t[:name] == 'main' } 78 | @task1 = @role1[:task].find {|t| t[:name] == 'task1' } 79 | end 80 | 81 | def test_role 82 | assert_keys @role1, *ROLE_KEYS 83 | assert_has_all %w(roleA), @role1[:role_deps] 84 | assert_has_all %w(main task1 task2), @role1[:task].smap(:name) 85 | assert_has_all %w(main maininc extra), @role1[:varfile].smap(:name) 86 | assert_has_all %w(main), @role1[:vardefaults].smap(:name) 87 | end 88 | 89 | def test_main 90 | assert_keys @main, *TASK_KEYS 91 | assert_equal @role1, @main[:parent] 92 | assert_has_all [["task2.yml", ["meow"]]], @main[:included_tasks] 93 | assert_has_all %w(maininc.yml), @main[:included_varfiles] 94 | assert_has_all %w(fact1main), @main[:var].smap(:name) 95 | assert_has_all %w(), @main[:args] 96 | assert_has_all %w(), @main[:included_by_tasks] 97 | end 98 | 99 | def test_task 100 | assert_keys @task1, *TASK_KEYS 101 | assert_equal @role1, @task1[:parent] 102 | assert_has_all %w(), @task1[:included_tasks] 103 | assert_has_all %w(fact1unused), @task1[:var].smap(:name) 104 | assert_has_all %w(extra.yml), @task1[:included_varfiles] 105 | assert_has_all %w(service), @task1[:args] 106 | assert_has_all %w(), @task1[:included_by_tasks] 107 | end 108 | 109 | def test_vars 110 | varfile = @role1[:varfile].find {|vf| vf[:name] == 'extra' } 111 | 112 | assert_keys varfile, :data, :parent, :var 113 | assert_has_all %w(var1extra), varfile[:var].smap(:name) 114 | varfile[:var].each {|var| 115 | assert !var[:used] 116 | assert var[:defined] 117 | } 118 | end 119 | 120 | def test_playbook1 121 | playbook1 = @d[:playbook].find {|pb| pb[:name] == 'playbook1' } 122 | 123 | assert_keys playbook1, :data, :include, :role, :task 124 | assert_has_all ["playbookA.yml"], playbook1[:include] 125 | assert_has_all [@role1, @roleA], playbook1[:role] 126 | assert_has_all %w(task1 taskA), playbook1[:task].smap(:name) 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | RSpec.configure do |config| 19 | # rspec-expectations config goes here. You can use an alternate 20 | # assertion/expectation library such as wrong or the stdlib/minitest 21 | # assertions if you prefer. 22 | config.expect_with :rspec do |expectations| 23 | # This option will default to `true` in RSpec 4. It makes the `description` 24 | # and `failure_message` of custom matchers include text for helper methods 25 | # defined using `chain`, e.g.: 26 | # be_bigger_than(2).and_smaller_than(4).description 27 | # # => "be bigger than 2 and smaller than 4" 28 | # ...rather than: 29 | # # => "be bigger than 2" 30 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 31 | end 32 | 33 | # rspec-mocks config goes here. You can use an alternate test double 34 | # library (such as bogus or mocha) by changing the `mock_with` option here. 35 | config.mock_with :rspec do |mocks| 36 | # Prevents you from mocking or stubbing a method that does not exist on 37 | # a real object. This is generally recommended, and will default to 38 | # `true` in RSpec 4. 39 | mocks.verify_partial_doubles = true 40 | end 41 | 42 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 43 | # have no way to turn it off -- the option exists only for backwards 44 | # compatibility in RSpec 3). It causes shared context metadata to be 45 | # inherited by the metadata hash of host groups and examples, rather than 46 | # triggering implicit auto-inclusion in groups with matching metadata. 47 | config.shared_context_metadata_behavior = :apply_to_host_groups 48 | 49 | # The settings below are suggested to provide a good initial experience 50 | # with RSpec, but feel free to customize to your heart's content. 51 | =begin 52 | # This allows you to limit a spec run to individual examples or groups 53 | # you care about by tagging them with `:focus` metadata. When nothing 54 | # is tagged with `:focus`, all examples get run. RSpec also provides 55 | # aliases for `it`, `describe`, and `context` that include `:focus` 56 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 57 | config.filter_run_when_matching :focus 58 | 59 | # Allows RSpec to persist some state between runs in order to support 60 | # the `--only-failures` and `--next-failure` CLI options. We recommend 61 | # you configure your source control system to ignore this file. 62 | config.example_status_persistence_file_path = "spec/examples.txt" 63 | 64 | # Limits the available syntax to the non-monkey patched syntax that is 65 | # recommended. For more details, see: 66 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 67 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 68 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 69 | config.disable_monkey_patching! 70 | 71 | # This setting enables warnings. It's recommended, but in some cases may 72 | # be too noisy due to issues in dependencies. 73 | config.warnings = true 74 | 75 | # Many RSpec users commonly either run the entire suite or an individual 76 | # file, and it's useful to allow more verbose output when running an 77 | # individual spec file. 78 | if config.files_to_run.one? 79 | # Use the documentation formatter for detailed output, 80 | # unless a formatter has already been configured 81 | # (e.g. via a command-line flag). 82 | config.default_formatter = "doc" 83 | end 84 | 85 | # Print the 10 slowest examples and example groups at the 86 | # end of the spec run, to help surface which specs are running 87 | # particularly slow. 88 | config.profile_examples = 10 89 | 90 | # Run specs in random order to surface order dependencies. If you find an 91 | # order dependency and want to debug it, you can fix the order by providing 92 | # the seed, which is printed after each run. 93 | # --seed 1234 94 | config.order = :random 95 | 96 | # Seed global randomization in this process using the `--seed` CLI option. 97 | # Setting this allows you to use `--seed` to deterministically reproduce 98 | # test failures related to randomization by passing the same `--seed` value 99 | # as the one that triggered the failure. 100 | Kernel.srand config.seed 101 | =end 102 | end 103 | 104 | # disable debug; we don't want to write to std during tests 105 | $debug_level = 0 106 | -------------------------------------------------------------------------------- /lib/ansible_viz/postprocessor.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'ostruct' 6 | require 'pp' 7 | 8 | 9 | class Postprocessor 10 | def initialize(options) 11 | @options = options 12 | end 13 | 14 | def process(dict) 15 | dict[:role].each {|role| process_role(dict, role) } 16 | dict[:task] = dict[:role].flat_map {|role| role[:task] } 17 | # Must process playbooks after tasks 18 | dict[:playbook].each {|playbook| process_playbook(dict, playbook) } 19 | end 20 | 21 | def process_role(dict, role) 22 | role[:task] ||= [] 23 | role[:task].each {|task| process_task(dict, task) } 24 | role[:main_task] = role[:task].find {|task| task[:name] == 'main' } 25 | 26 | [:varfile, :vardefaults].each {|type| 27 | role[type] ||= [] 28 | role[type].each {|varfile| process_vars(role, varfile) } 29 | } 30 | 31 | role[:template] ||= [] 32 | end 33 | 34 | def process_vars(dict, varfile) 35 | data = varfile[:data] 36 | varfile[:var] = data.each.flat_map {|key, value| 37 | if key =~ /(_default|_updates)$/ 38 | [] 39 | else 40 | [thing(varfile, :var, key, varfile[:path], {:defined => true, :data => value})] 41 | end 42 | } 43 | end 44 | 45 | # Parse an argument string like "aa=11 bb=2{{ cc }}/{{ dd }}2", 46 | # replace interpolations with "x" to obtain "aa=11 bb=2x2", then 47 | # convert to a Hash: {'aa' => "11", 'bb' => '2x2'} 48 | def parse_args(s) 49 | s.gsub(/{{(.*?)}}/, "x"). 50 | split(" "). 51 | map {|i| i.split("=") }. 52 | reduce({}) {|acc, pair| acc.merge(Hash[*pair]) } 53 | end 54 | 55 | # Parse an include directive of the form: 56 | # 57 | # foo.yml param1=bar1 param2=bar2 tags=qux 58 | # 59 | # discarding any "tags" parameter, and return: 60 | # 61 | # ["foo.yml", { "param1" => "bar1", "param2" => "bar2" }] 62 | def parse_include(s, type, origin) 63 | s.gsub!(/\{\{\s*playbook_dir\s*\}\}\//, '') 64 | 65 | # We want to skip lines like: 66 | # 67 | # - include: "{{ expression }}" 68 | # 69 | # but not lines like: 70 | # 71 | # - include: foo.yml var={{ value }} 72 | if s =~ /^\S+\{\{.*\}\}/ 73 | $stderr.puts "WARNING: skipping dynamic include '#{s}' " + 74 | "from #{type} '#{origin}' " + 75 | "since expressions are not supported yet." 76 | return [s, {}] 77 | end 78 | 79 | elements = s.split(" ") 80 | taskname = elements.shift 81 | args = parse_args(elements.join(" ")).keys.reject {|k| k == 'tags' } 82 | [taskname, args] 83 | end 84 | 85 | def process_playbook(dict, playbook) 86 | debug 3, "process_playbook(#{playbook[:fqn]})" 87 | debug 5, playbook.pretty_inspect.gsub(/^/, ' ') 88 | playbook[:include] = [] 89 | playbook[:role] = [] 90 | playbook[:task] = [] 91 | 92 | playbook[:data].each {|data| 93 | if data.keys.include? 'include' 94 | playbook[:include].push data['include'] 95 | end 96 | 97 | playbook[:role] += (data['roles'] || []).map {|role_or_name| 98 | role_name = role_or_name.instance_of?(Hash) ? 99 | role_or_name['role'] : role_or_name 100 | role = dict[:role].find {|r| r[:name] == role_name } 101 | unless role 102 | debug 1, "WARNING: Couldn't find role '#{role_name}' "\ 103 | "(invoked by playbook '#{playbook[:name]}')" 104 | end 105 | role 106 | }.compact 107 | 108 | playbook[:task] += (data['tasks'] || []).map {|task_hash| 109 | next nil unless task_hash['include'] 110 | debug 5, " adding task #{task_hash}" 111 | path, args = parse_include(task_hash['include'], 112 | "playbook", playbook[:fqn]) 113 | if path !~ %r!roles/([^/]+)/tasks/([^/]+)\.yml! 114 | debug 2, "Couldn't parses include from playbook #{playbook[:name]}: #{path}" 115 | next nil 116 | end 117 | rolename, taskname = $1, $2 118 | role = dict[:role].find {|r| r[:name] == rolename } 119 | unless role 120 | debug 1, "WARNING: Couldn't find role '#{rolename}' " \ 121 | "(referenced by task '#{taskname}' included by " \ 122 | "playbook '#{playbook[:fqn]}')" 123 | next nil 124 | end 125 | debug 4, " found task's role #{role[:fqn]}" 126 | task = role[:task].find {|t| t[:name] == taskname } 127 | debug 4, " found task #{taskname}" 128 | task[:args] += args 129 | task 130 | }.compact 131 | } 132 | 133 | playbook[:task].uniq! 134 | end 135 | 136 | def process_task(dict, task) 137 | debug 3, "process_task(#{task[:fqn]})" 138 | data = task[:data] 139 | 140 | task[:args] = [] 141 | task[:included_by_tasks] = [] 142 | 143 | task[:included_tasks] = data.find_all {|i| 144 | i.is_a? Hash and i['include'] 145 | }.map {|i| parse_include(i['include'], "task", task[:fqn]) } 146 | 147 | task[:included_varfiles] = data.find_all {|i| 148 | i.is_a? Hash and i['include_vars'] 149 | }.map {|i| i['include_vars'] } 150 | debug 5, " included_varfiles: #{task[:included_varfiles].inspect}" 151 | 152 | # A fact is created by set_fact in a task. A fact defines a var for every 153 | # task which includes this task. Facts defined by the main task of a role 154 | # are defined for all tasks which include this role. 155 | task[:var] = data.map {|i| 156 | i['set_fact'] 157 | }.compact.flat_map {|i| 158 | if i.is_a? Hash 159 | i.keys 160 | else 161 | [i.split("=")[0]] 162 | end 163 | }.map {|n| 164 | thing(task, :var, n, task[:path], {:defined => true}) 165 | } 166 | 167 | task[:used_templates] = data.flat_map {|subtask| 168 | if subtask.include?("template") 169 | line = subtask["template"] 170 | debug 4, " Processing template line: #{line}" 171 | args = case line 172 | when Hash then line 173 | when String then parse_args(line) 174 | else raise "Bad type: #{line.class}" 175 | end 176 | debug 5, " Args: #{args}" 177 | [args["src"].sub(/(.*)\..*/, '\1')] 178 | [args["src"].sub(/\.j2$/, '')] 179 | else [] 180 | end 181 | } 182 | if task[:used_templates].length > 0 183 | debug 6, ' ' * 6 + "used_templates:\n" + 184 | wrap_indent(' ' * 9, task[:used_templates]) 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/ansible_viz/grapher.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'ansible_viz/graphviz' 6 | require 'ansible_viz/styler' 7 | require 'pp' 8 | 9 | 10 | class Grapher 11 | def graph(dict, options) 12 | g = Graph.new 13 | g[:tooltip] = ' ' 14 | g[:label] = "Ansible dependencies" 15 | g[:fontsize] = 36 16 | g.rank_fn = Proc.new {|node| rank_node(node) } 17 | 18 | styler = Styler.new 19 | add_nodes(g, dict, styler, options.show_usage) 20 | connect_playbooks(g, dict, styler) 21 | connect_roles(g, dict, styler) 22 | if options.show_usage 23 | connect_usage(g, dict, styler) 24 | end 25 | 26 | styler.decorate(g, dict, options) 27 | 28 | cut(g, options) 29 | 30 | g 31 | end 32 | 33 | def cut(g, options) 34 | %w(var varfile vardefaults template task).each do |type| 35 | option = "show_%ss" % type.gsub(/s$/, '') 36 | if not options.send option 37 | to_cut = g.nodes.find_all {|n| n.data[:type] == type.to_sym } 38 | g.cut(*to_cut) 39 | end 40 | end 41 | 42 | if options.exclude_nodes 43 | exclude_nodes = g.nodes.find_all {|n| 44 | descriptor = "%s:%s" % [n.data[:type], n.data[:fqn]] 45 | exclude = descriptor =~ options.exclude_nodes 46 | if exclude 47 | debug 3, "Excluding node #{descriptor}" 48 | debug 4, " matched #{options.exclude_nodes}" 49 | end 50 | exclude 51 | } 52 | g.cut(*exclude_nodes) 53 | end 54 | 55 | if options.exclude_edges 56 | exclude_edges = g.edges.find_all {|e| 57 | descriptor = "%s:%s -> %s:%s" % [ 58 | e.snode.data[:type], e.snode.data[:fqn], 59 | e.dnode.data[:type], e.dnode.data[:fqn] 60 | ] 61 | exclude = descriptor =~ options.exclude_edges 62 | if exclude 63 | debug 3, "Excluding edge #{descriptor}" 64 | debug 4, " matched #{options.exclude_edges}" 65 | end 66 | exclude 67 | } 68 | g.cut(*exclude_edges) 69 | end 70 | end 71 | 72 | def rank_node(node) 73 | return nil 74 | end 75 | 76 | def add_node(g, it) 77 | node = GNode[it[:fqn]] 78 | node.data = it 79 | it[:node] = node 80 | g.add(node) 81 | end 82 | 83 | def add_nodes(g, dict, styler, show_usage) 84 | vars = [] 85 | dict[:role].each {|role| 86 | add_node(g, role) 87 | role[:task].each {|task| 88 | add_node(g, task) 89 | vars += task[:var] 90 | } 91 | (role[:varfile] + role[:vardefaults]).each {|vf| 92 | add_node(g, vf) 93 | vars += vf[:var] 94 | } 95 | role[:template].each {|tm| 96 | add_node(g, tm) 97 | } 98 | } 99 | vars.each {|v| 100 | if show_usage or v[:defined] 101 | add_node(g, v) 102 | end 103 | } 104 | dict[:playbook].each {|playbook| 105 | add_node(g, playbook) 106 | # Roles and tasks should already have nodes 107 | } 108 | end 109 | 110 | def add_edge(g, src, dst, tooltip, extra={}) 111 | raise "Nil src for edge, tooltip: #{tooltip}" if src.nil? 112 | raise "Bad src: #{src[:name]}" if src[:node].nil? 113 | 114 | raise "Nil dst for edge, tooltip: #{tooltip}" if dst.nil? 115 | raise "Bad dst: #{dst[:name]}" if dst[:node].nil? 116 | 117 | edge = GEdge[src[:node], dst[:node], {:tooltip => tooltip}.merge(extra)] 118 | g.add edge 119 | edge 120 | end 121 | 122 | def connect_playbooks(g, dict, styler) 123 | dict[:playbook].each {|playbook| 124 | (playbook[:include] || []).each {|pb| 125 | edge = add_edge(g, playbook, pb, 126 | "#{playbook[:fqn]} includes playbook #{pb[:fqn]}") 127 | styler.style(edge, :include_playbook) 128 | } 129 | (playbook[:role] || []).each {|role| 130 | add_edge(g, playbook, role, 131 | "#{playbook[:fqn]} invokes role #{role[:name]}") 132 | } 133 | (playbook[:task] || []).each {|task| 134 | edge = add_edge(g, playbook, task, 135 | "#{playbook[:fqn]} calls task #{task[:fqn]}") 136 | styler.style(edge, :call_task) 137 | } 138 | } 139 | end 140 | 141 | def connect_roles(g, dict, styler) 142 | dict[:role].each {|role| 143 | (role[:role_deps] || []).each {|dep| 144 | edge = add_edge(g, role, dep, "#{role[:fqn]} includes role #{dep[:fqn]}") 145 | styler.style(edge, :includes_role) 146 | } 147 | 148 | role[:task].each {|task| 149 | connect_task(g, dict, task, styler) 150 | } 151 | 152 | (role[:varfile] || []).each {|vf| 153 | add_edge(g, role, vf, "#{role[:fqn]} uses varfile #{vf[:fqn]}") 154 | vf[:var].each {|v| 155 | add_edge(g, vf, v, "#{vf[:fqn]} defines var #{v[:fqn]}") 156 | } 157 | } 158 | 159 | (role[:vardefaults] || []).each {|vf| 160 | add_edge(g, role, vf, "#{role[:fqn]} uses default varfile #{vf[:fqn]}") 161 | vf[:var].each {|v| 162 | add_edge(g, vf, v, "#{vf[:fqn]} defines var #{v[:fqn]}") 163 | } 164 | } 165 | 166 | (role[:template] || []).each {|tm| 167 | add_edge(g, role, tm, "#{role[:fqn]} provides template #{tm[:fqn]}") 168 | } 169 | } 170 | end 171 | 172 | def connect_task(g, dict, task, styler) 173 | add_edge(g, task[:parent], task, 174 | "#{task[:parent][:fqn]} calls #{task[:fqn]}") 175 | 176 | task[:var].each {|var| 177 | if var[:defined] 178 | add_edge(g, task, var, "#{task[:fqn]} sets fact #{var[:fqn]}") 179 | end 180 | } 181 | 182 | task[:included_tasks].each {|incl_task| 183 | privet = (task[:parent] != incl_task[:parent] and incl_task[:name][0] == '_') 184 | style = if privet then :private else :includes_task end 185 | styler.style(add_edge(g, task, incl_task, 186 | "#{task[:fqn]} includes task #{incl_task[:fqn]}"), style) 187 | } 188 | 189 | task[:used_templates].each {|tm| 190 | styler.style(add_edge(g, task, tm, 191 | "#{task[:fqn]} applies template #{tm[:fqn]}"), 192 | :applies_template) 193 | } 194 | end 195 | 196 | def connect_usage(g, dict, styler) 197 | dict[:role].each {|role| 198 | [:task, :varfile, :vardefaults, :template]. 199 | flat_map {|sym| role[sym] }. 200 | each {|thing| 201 | (thing[:uses] || []).each {|var| 202 | edge = add_edge(g, thing, var, 203 | "#{thing[:fqn]} uses var #{var[:fqn]}") 204 | styler.style(edge, :use_var) 205 | } 206 | } 207 | } 208 | end 209 | 210 | # This helps prevent unlinked nodes distorting the graph when it's all messed up. 211 | # Normally there shouldn't be (m)any unlinked nodes. 212 | def extract_unlinked(g) 213 | spare = g.nodes.find_all {|n| 214 | (n.inc_nodes + n.out_nodes).length == 0 215 | } 216 | g.cut(*spare) 217 | 218 | unlinked = Graph.new_cluster('unlinked') 219 | unlinked.add(*spare) 220 | unlinked[:bgcolor] = Styler.hsl(15, 0, 97) 221 | unlinked[:label] = "Unlinked nodes" 222 | unlinked[:fontsize] = 36 223 | unlinked.rank_fn = Proc.new {|node| node.data[:type] } 224 | unlinked 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /lib/ansible_viz/graphviz.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'set' 5 | 6 | ########## MODELLING ########## 7 | 8 | class GNode 9 | class < #{dnode.inspect}#{hsh}]" 82 | end 83 | end 84 | 85 | 86 | class Graph 87 | class < ix + 1, 107 | :labeldistance => 2.0 }] 108 | } 109 | } 110 | g 111 | end 112 | 113 | def new_subgraph(name) 114 | g = Graph.new 115 | g.name = name 116 | g 117 | end 118 | def new_cluster(name) 119 | g = Graph.new 120 | g.name = name 121 | g.is_cluster = true 122 | g 123 | end 124 | end 125 | 126 | @@cluster_counter = 0 127 | attr_accessor :nodes, :edges, :subgraphs, :attrs 128 | attr_accessor :is_cluster, :name 129 | attr_accessor :rank_fn 130 | 131 | def initialize(nodes = [], edges = [], attrs = {}) 132 | @nodes = nodes.dup 133 | @edges = [] 134 | add(*edges) 135 | @attrs = attrs 136 | @subgraphs = [] 137 | @is_cluster = false 138 | @name = 'Graph' 139 | @rank_fn = Proc.new {|node| nil } 140 | end 141 | 142 | def initialize_copy(src) 143 | @nodes = @nodes.map {|i| i.dup }.to_set 144 | @edges = [] 145 | src.edges.each {|i| 146 | add GEdge[get(i.snode.key), get(i.dnode.key), i.attrs.dup] 147 | } 148 | @attrs = @attrs.dup 149 | @subgraphs = @subgraphs.map {|sg| Graph.new.initialize_copy(sg) } 150 | end 151 | 152 | def [](k); attrs[k]; end 153 | def []=(k, v); attrs[k] = v; end 154 | 155 | def get(k1, k2=nil) 156 | case k2 157 | when nil 158 | @nodes.each {|n| 159 | return n if n.key == k1 160 | } 161 | else 162 | @edges.each {|e| 163 | return n if n.snode.key == k1 and n.dnode.key == k2 164 | } 165 | end 166 | nil 167 | end 168 | def get_or_make(k) 169 | n = get(k) 170 | if n.nil? 171 | n = GNode[k] 172 | add n 173 | end 174 | n 175 | end 176 | 177 | def add(*items) 178 | items.each {|i| 179 | case i 180 | when GNode 181 | # puts "N+++: #{i.inspect}" 182 | @nodes.push i 183 | when GEdge 184 | # puts "E+++: #{i.inspect}" 185 | @nodes.push i.snode 186 | @nodes.push i.dnode 187 | @edges.push i 188 | i.snode.out.push i 189 | i.dnode.inc.push i 190 | # puts "E---: #{i.inspect}" 191 | when Graph 192 | @subgraphs.unshift i 193 | else raise "Unexpected item: #{i.inspect}" 194 | end 195 | } 196 | end 197 | 198 | def cut(*items) 199 | items.each {|i| 200 | case i 201 | when GNode 202 | @nodes.delete i 203 | cut(*(i.out + i.inc)) 204 | when GEdge 205 | @edges.delete i 206 | i.snode.out.delete i 207 | i.dnode.inc.delete i 208 | when Graph 209 | @subgraphs.delete i 210 | else raise "Unexpected item: #{i.inspect}" 211 | end 212 | } 213 | @subgraphs.each {|sg| sg.cut(*items) } 214 | end 215 | 216 | def lowercut(*items) 217 | items.each {|i| 218 | # puts "cut: #{i.inspect}" 219 | case i 220 | when GNode 221 | @nodes.delete i 222 | lowercut(*(i.out + i.inc)) 223 | when GEdge 224 | cut i 225 | if i.dnode.inc.empty? 226 | lowercut i.dnode 227 | end 228 | else raise "Unexpected item: #{i.inspect}" 229 | end 230 | } 231 | end 232 | 233 | def focus(*nodes) 234 | # TODO: make new nodes + edges 235 | keep_nodes = [] 236 | keep_edges = [] 237 | to_walk = Array.new(nodes) 238 | while !to_walk.empty? 239 | item = to_walk.pop 240 | keep_nodes << item 241 | keep_edges += item.inc 242 | to_walk += item.inc_nodes - keep_nodes 243 | end 244 | to_walk = Array.new(nodes) 245 | while !to_walk.empty? 246 | item = to_walk.pop 247 | keep_nodes << item 248 | keep_edges += item.out 249 | to_walk += item.out_nodes - keep_nodes 250 | end 251 | Graph.new(keep_nodes, keep_edges, @attrs) 252 | end 253 | 254 | def rank_nodes 255 | Set[*@nodes].classify {|n| @rank_fn.call(n) } 256 | end 257 | 258 | def inspect 259 | [(Graph.include_hashes and "Hash: ##{hash}" or nil), 260 | "Nodes:", 261 | @nodes.map {|n| " "+ n.inspect }, 262 | "Edges:", 263 | @edges.map {|e| " "+ e.inspect }, 264 | "Subgraphs:", 265 | @subgraphs.map {|sg| 266 | sg.inspect.split($/).map {|line| " "+ line } 267 | }]. 268 | compact. 269 | join("\n") 270 | end 271 | end 272 | 273 | ########## GENERATE DOT ############# 274 | 275 | def joinattrs(h) 276 | h.select {|k,v| v != nil }. 277 | map {|k,v| "#{k}=\"#{v}\""} 278 | end 279 | def wrapattrs(h) 280 | a = joinattrs(h).join(", ") 281 | a.length > 0 and " [#{a}]" or "" 282 | end 283 | 284 | def g2dot(graph, level=0) 285 | gtype = ((level > 0 and "subgraph") or "digraph") 286 | gname = ((graph.is_cluster and "cluster#{graph.name}") or graph.name) 287 | 288 | # Subgraphs go first so they don't inherit attributes 289 | body = [] 290 | body += graph.subgraphs.flat_map {|sg| g2dot(sg, level + 1).split("\n") } 291 | body += joinattrs(graph.attrs) 292 | 293 | body += graph.rank_nodes. 294 | flat_map {|k,v| 295 | rnodes = v.map {|n| 296 | n.node + wrapattrs(n.attrs) +";" 297 | } 298 | tab_rnodes = rnodes.map {|line| " " + line } 299 | 300 | case k 301 | when nil then rnodes 302 | when :source, :min, :max, :sink 303 | ["{ rank = #{k};", *tab_rnodes, "}"] 304 | when :same 305 | raise "Don't use 'same' rank directly" 306 | else 307 | ["{ rank = same;", *tab_rnodes, "}"] 308 | end 309 | } 310 | body += graph.edges.map {|e| 311 | "#{e.snode.node} -> #{e.dnode.node}#{wrapattrs(e.attrs)};" 312 | } 313 | 314 | body.map! {|line| " " + line } 315 | ["#{gtype} \"#{gname}\" {", *body, "}"]. 316 | map {|line| line + "\n" }.join("") 317 | end 318 | -------------------------------------------------------------------------------- /lib/ansible_viz/resolver.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'ostruct' 6 | require 'pp' 7 | 8 | 9 | class Resolver 10 | # Converts includes from names/paths to things. 11 | # Ensure you've loaded the whole bag before trying to resolve it. 12 | 13 | def process(dict) 14 | dict[:playbook].each {|playbook| 15 | resolve_playbook_includes(dict, playbook) 16 | } 17 | dict[:role].each {|role| 18 | resolve_role_deps(dict, role) 19 | } 20 | dict[:task].each {|task| 21 | resolve_task_includes(dict, task) 22 | resolve_task_include_vars(dict, task) 23 | } 24 | dict[:task].each {|task| 25 | task[:included_by_tasks].uniq! 26 | resolve_args(dict, task) 27 | resolve_templates(dict, task) 28 | } 29 | end 30 | 31 | def resolve_playbook_includes(dict, playbook) 32 | playbook[:include].map! {|name| 33 | name.sub!(/.yml$/, '') 34 | included_playbook = dict[:playbook].find {|pb| pb[:name] == name } 35 | unless included_playbook 36 | debug 1, "WARNING: Couldn't find playbook '#{name}' " \ 37 | "(included by playbook '#{playbook[:fqn]}')" 38 | included_playbook = thing(dict, :playbook, name, 39 | "unknown", unresolved: true, 40 | include: []) 41 | end 42 | included_playbook 43 | } 44 | end 45 | 46 | def find_role_by_name(dict, rolename, context) 47 | role = dict[:role].find {|r| r[:name] == rolename } 48 | unless role 49 | debug 1, "WARNING: Couldn't find role '#{rolename or 'nil'}' " + context 50 | role = mk_unresolved_role(dict, rolename) 51 | end 52 | role 53 | end 54 | 55 | # Finds something of the given type and name within the given role. 56 | # The role can be a Hash or the name of a role. 57 | def find_on_role(dict, role, type, name) 58 | if !role.is_a?(Hash) 59 | # Role name supplied; find the corresponding role Hash. 60 | role_name = role 61 | role = find_role_by_name(dict, role_name, 62 | "while finding #{type} named '#{name}' on it") 63 | end 64 | debug 4, " Find #{type} '#{name}' in role '#{role[:name]}'" 65 | role[type].find {|t| t[:name] == name } 66 | end 67 | 68 | def mk_unresolved_role(dict, role_name, path="unknown") 69 | thing(dict, :role, role_name, path, 70 | unresolved: true, role_deps: [], 71 | task: [], varfile: [], vardefaults: [], template: []) 72 | end 73 | 74 | # Returns [task, task_name, role]. role will be auto-vivified if 75 | # unresolvable, but task returned may be nil, to allow the caller to 76 | # report the context in which the task failed to resolve. 77 | def find_task(dict, role, name) 78 | debug 4, " find_task(#{role[:name]}, #{name})" 79 | task_name = name.sub(/\.yml$/, '') 80 | if task_name =~ %r!^(?:roles/|\.\./\.\./(?:\.\./roles/)?)([^/]+)/tasks/([^/]+)$! 81 | role_name, task_name = $1, $2 82 | debug 4, " finding role '#{role_name}' elsewhere in '#{task_name}'" 83 | role = find_role_by_name(dict, role_name, 84 | "while looking for task '#{name}'") 85 | end 86 | 87 | task = find_on_role(dict, role, :task, task_name) 88 | [task, task_name, role] 89 | end 90 | 91 | def find_template(dict, role, name) 92 | debug 4, " find_template('#{role[:name]}', '#{name}')" 93 | if name =~ %r!^(?:roles/|\.\./\.\./(?:\.\./roles/)?)([^/]+)/templates/(.+)$! 94 | role_name, template_name = $1, $2 95 | debug 4, " Finding template '#{template_name}' " + 96 | "elsewhere in '#{role_name}'" 97 | role = find_role_by_name(dict, role_name, 98 | "while looking for template '#{template_name}'") 99 | name = template_name 100 | end 101 | find_on_role(dict, role, :template, name) 102 | end 103 | 104 | # dict[:role] is Array of role Hashes 105 | # keys: name, type, ... 106 | def resolve_role_deps(dict, role) 107 | role[:role_deps] = role[:role_deps].map {|depname| 108 | if depname =~ /\{\{.*\}\}/ 109 | debug 4, "WARNING: skipping dynamic dependency of #{role[:name]} " + 110 | "role on:\n" + 111 | depname + "\n" + 112 | "since expressions are not supported yet." 113 | next "dynamic dependency of #{role[:name]}" 114 | end 115 | 116 | find_role_by_name(dict, depname, "(dependency of role '#{role[:fqn]}')") 117 | } 118 | if role[:role_deps].any?(&:nil?) 119 | raise "nil role dep for #{role[:fqn]}" 120 | end 121 | end 122 | 123 | # dict[:task] is Array of task Hashes; task is same Hash 124 | # keys: name, type, fqn, data, parent, args, ... 125 | def resolve_task_includes(dict, task) 126 | task[:included_tasks].map! {|name, args| 127 | debug 4, "Finding task '#{name}' included in task '#{task[:fqn]}'" 128 | incl_task, incl_task_name, role = find_task(dict, task[:parent], name) 129 | if incl_task.nil? 130 | debug 1, "WARNING: Couldn't find task '#{name}' "\ 131 | "(included by task '#{task[:fqn]}')" 132 | incl_task = thing(role, :task, incl_task_name, "unknown", 133 | unresolved: true, args: [], 134 | included_by_tasks: [], scope: [], var: [], 135 | included_varfiles: [], included_tasks: [], 136 | used_templates: [], 137 | data: { registers: [] }) 138 | incl_task 139 | end 140 | 141 | incl_task[:args] += args 142 | incl_task[:included_by_tasks].push task 143 | incl_task 144 | } 145 | end 146 | 147 | def resolve_task_include_vars(dict, task) 148 | task[:included_varfiles].map! {|name| 149 | begin 150 | if name =~ %r!\{\{.+\}\}! 151 | thing(task, :varfile, 152 | "\"#{name}\" in " + task[:fqn], 153 | task[:path], 154 | {:include => name, :var => []}) 155 | elsif name =~ %r!^([^/]+).yml! or name =~ %r!^\.\./vars/([^/]+).yml! 156 | find_on_role(dict, task[:parent], :varfile, $1) or 157 | mk_unresolved_varfile(dict, $1) 158 | elsif name =~ %r!^\.\./defaults/([^/]+).yml! 159 | find_on_role(dict, task[:parent], :vardefaults, $1) or 160 | mk_unresolved_vardefaults(dict, $1) 161 | elsif name =~ %r!^(?:\.\./\.\./|roles/)([^/]+)/vars/([^/]+).yml! 162 | find_on_role(dict, $1, :varfile, $2) or 163 | mk_unresolved_varfile(dict, $1) 164 | elsif name =~ %r!^(?:\.\./\.\./|roles/)([^/]+)/defaults/([^/]+).yml! 165 | find_on_role(dict, $1, :vardefaults, $2) or 166 | mk_unresolved_vardefaults(dict, $1) 167 | else 168 | raise "Unhandled include_vars: #{name}" 169 | end 170 | rescue Exception 171 | debug 0, "Problem resolving task '#{task[:fqn]}' include_vars: '#{name}'" 172 | raise 173 | end 174 | } 175 | if task[:included_varfiles].include?(nil) 176 | raise "Task #{task[:fqn]}'s included varfiles includes a nil: " + 177 | task[:included_varfiles].inspect 178 | end 179 | end 180 | 181 | def mk_unresolved_varfile(dict, name) 182 | thing(dict, :varfile, name, "unknown", unresolved: true, var: []) 183 | end 184 | 185 | def mk_unresolved_vardefaults(dict, name) 186 | thing(dict, :vardefaults, name, "unknown", unresolved: true, var: []) 187 | end 188 | 189 | def resolve_args(dict, task) 190 | task[:args] = task[:args].uniq.map {|arg| 191 | thing(task, :var, arg, task[:path], {:defined => true}) 192 | } 193 | end 194 | 195 | def resolve_templates(dict, task) 196 | task[:used_templates].map! {|template| 197 | debug 4, "Finding template '#{template}' used in #{task[:fqn]}" 198 | if template =~ %r!\{\{.*\}\}! 199 | thing(task[:parent], :template, 200 | "dynamic template src in " + task[:fqn], 201 | task[:path], src: template, data: {}) 202 | else 203 | find_template(dict, task[:parent], template) or 204 | begin 205 | debug 1, "WARNING: Couldn't find template '#{template}' " \ 206 | "(included by task '#{task[:fqn]}')" 207 | thing(task[:parent], :template, template, 208 | "unknown", unresolved: true, 209 | src: template, data: {}) 210 | end 211 | end 212 | } 213 | if task[:used_templates].length > 0 214 | templates = task[:used_templates].map {|tm| tm[:fqn] } 215 | debug 4, " used_templates: " + templates.join(" ") 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/ansible_viz/scoper.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # vim: set ts=2 sw=2 tw=100: 3 | 4 | require 'rubygems' 5 | require 'ostruct' 6 | require 'pp' 7 | require 'word_wrap' 8 | require 'word_wrap/core_ext' 9 | 10 | 11 | class Scoper 12 | # Most 'business logic' should live here, IE calculating fancy stuff. 13 | # Must ensure everything grapher expects is set, even if to empty []/{}. 14 | # 15 | # Vars can appear: 16 | # * In role[:varfile][:var], when defined in vars/ 17 | # * In role[:task][:var], when defined by set_fact 18 | # 19 | # Also task[:scope] exists 20 | 21 | def process(dict) 22 | # Sweep up, collecting defined vars onto each role; 23 | # then sweep down checking all used vars are defined. 24 | 25 | bottomup = order_tasks(dict[:role]) 26 | topdown = bottomup.reverse 27 | 28 | topdown.each {|task| 29 | # Copy :args down to every task this one includes 30 | task[:included_tasks].each {|t| t[:args] += task[:args] } 31 | } 32 | 33 | debug 2, "Calculating scope for each task, bottom up" 34 | bottomup.each {|task| 35 | # Exclude "vars" which are actually registered resultsets 36 | task[:registers] = find_registers(task, task[:data]) 37 | task[:registers] += task[:included_tasks].flat_map {|t| t[:registers] } 38 | task[:used_vars].reject! {|s| task[:registers].include? s } 39 | 40 | calc_scope(dict, task) 41 | task[:scope].each {|var| 42 | var[:used] = [] 43 | } 44 | } 45 | 46 | debug 2, "Checking used vars for each task, top down" 47 | topdown.each {|task| 48 | check_used_vars_for_task(dict, task) 49 | } 50 | 51 | # Construct a mapping of variable names to variables for all variables 52 | # in all tasks/varfiles/vardefaults of every role 53 | pairs = dict[:role].flat_map {|role| 54 | [:task, :varfile, :vardefaults]. 55 | flat_map {|sym| role[sym] }. 56 | flat_map {|t| t[:var] }. 57 | flat_map {|v| [v[:name], v] } 58 | } 59 | # FIXME this works but shows vars are not always unified correctly. 60 | # Might not be fixable for facts? 61 | # counts = pairs.group_by {|k,v| k }.map {|k,v| [k, v.count] } 62 | # counts.each {|k, v| puts "! #{v} vars with same name: #{k}" if v > 1 } 63 | dict[:vars_by_name] = Hash[*pairs] 64 | dict[:role].each {|role| 65 | [:varfile, :vardefaults, :template]. 66 | flat_map {|sym| role[sym] }. 67 | each {|vf| check_used_vars(dict, vf) } 68 | } 69 | end 70 | 71 | def find_registers(task, data) 72 | VarFinder.walk(data) {|type, obj| 73 | case type 74 | when :hash 75 | [obj['register']] 76 | end 77 | } 78 | end 79 | 80 | # Take a list of roles or tasks, and return another list of the same 81 | # items topologically sorted so that dependencies come before the 82 | # items which depend on them. 83 | # 84 | # Requires a block to be passed which when called with a given role 85 | # or task, will return an Array of its dependencies. 86 | def order_list(type, list) 87 | debug 3, "order_list() ordering #{type}s" 88 | debug 4, " [\n" + 89 | wrap_indent(' ' * 6, list.map { |item| item[:fqn] }) + 90 | " ]" 91 | todo = list.dup 92 | order = [] 93 | safe = 0 94 | while todo.length > 0 95 | item = todo.shift 96 | debug 4, " order_list() processing #{type} '#{item[:fqn]}', " \ 97 | "#{todo.length} still todo" 98 | deps = yield(item) 99 | deps.reject! {|i| 100 | (i.is_a?(String) && i =~ /dynamic dependency/) or 101 | (i.is_a?(Hash) && i[:unresolved]) 102 | } 103 | if deps.all? {|dep| 104 | unless dep.is_a? Hash 105 | raise "weird dep of #{type} '#{item[:fqn]}': #{dep.inspect}" 106 | end 107 | dep[:loaded] 108 | } 109 | item[:loaded] = true 110 | debug 5, " All #{deps.size} dependencies of #{type} '#{item[:fqn]}' loaded, "\ 111 | "pushing onto order" 112 | order.push item 113 | else 114 | debug 5, " Deps of #{type} '#{item[:fqn]}' not all loaded:\n" + 115 | wrap_indent(' ' * 9, deps.map {|it| it[:name] }) 116 | debug 5, " Pushing to back of todo list" 117 | todo.push item 118 | end 119 | safe += 1 120 | if safe > 500 121 | oops = todo.map {|it| it[:fqn] }.join(" ") 122 | raise "Oops, infinite recursion?\nTodo list was: #{oops}" 123 | end 124 | end 125 | order.each {|i| i.delete :loaded } 126 | debug 4, "Final order of #{type}s:\n" + 127 | wrap_indent(' ' * 3, list.map { |i| i[:fqn] }) 128 | order 129 | end 130 | 131 | def all_tasks(roles) 132 | roles = order_list("role", roles) {|role| role[:role_deps] } 133 | roles.flat_map {|role| role[:task] } 134 | end 135 | 136 | def order_tasks(roles) 137 | order_list("task", all_tasks(roles)) {|task| 138 | incl_tasks = task[:included_tasks].dup 139 | # :used_by_main is a pretty awful hack to break a circular scope dependency 140 | if task == task[:main_task] 141 | task[:included_tasks].each {|t| t[:used_by_main] == true } 142 | elsif not task[:used_by_main] 143 | incl_tasks += (task[:main_task] || []) 144 | end 145 | incl_tasks 146 | } 147 | end 148 | 149 | def raise_if_task_component_nil(task_name, component_name, it) 150 | if it.nil? 151 | raise "in task '#{task_name}', '#{component_name}' is nil" 152 | elsif it.include? nil 153 | raise "in task '#{task_name}', " \ 154 | "'#{component_name}' #{it.class} includes nil: #{it}" 155 | end 156 | end 157 | 158 | # Calculate scope of task, i.e. which variables are available to the task. 159 | # This method must be called in bottom-up dependency order. 160 | def calc_scope(dict, task) 161 | role = task[:parent] 162 | debug 3, "calc_scope(#{task[:fqn]}), parent #{role[:fqn]}" 163 | if role[:scope].nil? 164 | debug 4, " no scope for #{role[:fqn]} yet" 165 | main_vf = role[:varfile].find {|vf| vf[:name] == 'main' } || {:var => []} 166 | raise_if_task_component_nil(task[:fqn], "main_vf", main_vf) 167 | 168 | defaults = role[:vardefaults].flat_map {|vf| vf[:var] } 169 | raise_if_task_component_nil(task[:fqn], "defaults", defaults) 170 | 171 | # role[:scope] should really only be set after the main task has 172 | # been handled. main can include other tasks though, so to 173 | # break the circular dependency, allow a partial role[:scope] of 174 | # just the vars, defaults and dependent roles' scopes. 175 | dep_vars = role[:role_deps].flat_map {|dep| 176 | debug 5, "Checking dependency '#{dep[:fqn]}' of " + 177 | "role '#{role[:fqn]}'" 178 | 179 | if ! dep.has_key? :scope 180 | debug 1, "WARNING: dependency '#{dep[:fqn]}' of " + 181 | "role '#{role[:fqn]}' is missing scope; " + 182 | "guessing that it didn't have any tasks." 183 | nil 184 | else 185 | debug 5, " dependency '#{dep[:fqn]}' of role '#{role[:fqn]}' " + 186 | "has scope:\n" + 187 | wrap_indent(' ' * 6, dep[:scope].map {|i| i[:fqn]}) 188 | dep[:scope] 189 | end 190 | }.compact 191 | raise_if_task_component_nil(task[:fqn], "dependency scope", dep_vars) 192 | role[:scope] = main_vf[:var] + defaults + dep_vars 193 | end 194 | 195 | # This list must be in ascending precedence order 196 | task[:debug] = { 197 | :incl_varfiles => task[:included_varfiles].flat_map {|vf| vf[:var] }, 198 | :args => task[:args], 199 | :incl_scopes => task[:included_tasks].flat_map {|t| 200 | raise "task '#{t[:fqn]}' missing scope" unless t[:scope] 201 | t[:scope] 202 | }, 203 | :role_scope => role[:scope], 204 | :facts => task[:var] 205 | } 206 | task[:debug].each {|k,v| raise_if_task_component_nil(task[:fqn], k, v) } 207 | task[:scope] = task[:debug].values.inject {|a,i| a + i } 208 | if task == role[:main_task] 209 | # update the role[:scope] so it has the full picture 210 | role[:scope] = task[:scope] 211 | debug 4, " set full scope for role '#{role[:fqn]}' from task '#{task[:fqn]}'" 212 | end 213 | end 214 | 215 | def check_used_vars_for_task(dict, task) 216 | # By this point, each task has a :scope of [Var]. 217 | # We simply need to compare used_vars ([string]) with the vars and mark them used. 218 | scope_by_name = Hash[*(task[:scope].flat_map {|v| [v[:name], v] })] 219 | task[:used_vars].each {|use| 220 | var = scope_by_name[use] 221 | if var.nil? 222 | var = thing(task, :var, use, task[:path], {:defined => false}) 223 | end 224 | var[:used] ||= [] 225 | var[:used].push task 226 | task[:uses] ||= Set[] 227 | task[:uses].add var 228 | } 229 | end 230 | 231 | def check_used_vars(dict, thing) 232 | thing[:used_vars].each {|use| 233 | # Figuring out the varfile scope is pretty awful since it could be included 234 | # from anywhere. Let's just mark vars used. 235 | var = (thing[:var] || []).find {|v| v[:name] == use } 236 | if var.nil? 237 | # Using a heuristic of "if you have two vars with the same name 238 | # then you're a damned fool". 239 | var = dict[:vars_by_name][use] 240 | end 241 | if var.nil? 242 | # puts "Can't find var anywhere: #{use}" 243 | next 244 | end 245 | var[:used] ||= [] 246 | var[:used].push thing 247 | thing[:uses] ||= Set[] 248 | thing[:uses].add var 249 | # puts "#{thing[:fqn]} -> #{var[:fqn]}" 250 | } 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | --------------------------------------------------------------------------------