├── 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 | [](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 | 
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 |
--------------------------------------------------------------------------------