rzo 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,137 @@
1
+ require 'rzo/app/subcommand'
2
+ require 'pp'
3
+ require 'erb'
4
+
5
+ module Rzo
6
+ class App
7
+ ##
8
+ # Produce a `Vagrantfile` in the top level puppet control repo.
9
+ #
10
+ # Load all rizzo config files, then produce the Vagrantfile from an ERB
11
+ # template.
12
+ class Generate < Subcommand
13
+ attr_reader :config
14
+
15
+ # The main run method for the subcommand.
16
+ def run
17
+ exit_status = 0
18
+ load_config!
19
+ # Vagrantfile
20
+ erbfile = File.expand_path('../templates/Vagrantfile.erb', __FILE__)
21
+ content = vagrantfile_content(erbfile, config)
22
+ write_file(opts[:vagrantfile]) { |fd| fd.write(content) }
23
+ say "Wrote vagrant config to #{opts[:vagrantfile]}"
24
+ exit_status
25
+ end
26
+
27
+ ##
28
+ # Return a list of agent node definitions suitable for the Vagrantfile
29
+ # template.
30
+ #
31
+ # @param [Hash] config The configuration hash used to fill in the ERB
32
+ # template.
33
+ #
34
+ # @return [Array<Hash>] list of agent nodes to fill in the Vagrantfile
35
+ # template.
36
+ def vagrantfile_agents(config)
37
+ pm_settings = puppetmaster_settings(config)
38
+ agent_nodes = [*config['nodes']].reject do |n|
39
+ pm_settings['name'].include?(n['name'])
40
+ end
41
+
42
+ agent_nodes.each do |n|
43
+ n.deep_merge(config['defaults'])
44
+ log.debug "puppetagent #{n['name']} = \n" + n.pretty_inspect
45
+ end
46
+
47
+ agent_nodes
48
+ end
49
+
50
+ ##
51
+ # Return a list of puppetmaster node definitions suitable for the
52
+ # Vagrantfile template.
53
+ #
54
+ # @param [Hash] config The configuration hash used to fill in the ERB
55
+ # template.
56
+ #
57
+ # @return [Array<Hash>] list of puppet master nodes to fill in the
58
+ # Vagrantfile template.
59
+ #
60
+ # rubocop:disable Metrics/AbcSize
61
+ def vagrantfile_puppet_masters(config)
62
+ pm_settings = puppetmaster_settings(config)
63
+ pm_names = pm_settings['name']
64
+
65
+ nodes = [*config['nodes']].find_all { |n| pm_names.include?(n['name']) }
66
+ nodes.each do |n|
67
+ n.deep_merge(config['defaults'])
68
+ n.deep_merge(pm_settings)
69
+ n[:puppetmaster] = true
70
+ log.debug "puppetmaster #{n['name']} = \n" + n.pretty_inspect
71
+ end
72
+ end
73
+ # rubocop:enable Metrics/AbcSize
74
+
75
+ ##
76
+ # Return the proxy configuration exception list as a string, or nil if not set.
77
+ #
78
+ # @param [Hash] config The configuration hash used to fill in the ERB
79
+ # template.
80
+ #
81
+ # @return [String,nil] proxy exclusion list or nil if not specified.
82
+ def proxy_config(config)
83
+ # Proxy Setting
84
+ return nil unless config['config']
85
+ config['config']['no_proxy'] || DEFAULT_NO_PROXY
86
+ end
87
+
88
+ ##
89
+ # Return a timestamp to embed in the output Vagrantfile. This is a method
90
+ # so it may be stubbed out in the tests.
91
+ def timestamp
92
+ Time.now
93
+ end
94
+
95
+ ##
96
+ # Return a string which is the Vagrantfile content of a filled in
97
+ # Vagrantfile erb template. The configuration data parsed by load_config!
98
+ # is expected as input, along with the template to fill in.
99
+ #
100
+ # The base templates directory is relative to the directory containing
101
+ # this file.
102
+ #
103
+ # @param [String] template The fully qualified path to the ERB template.
104
+ #
105
+ # @param [Hash] config The configuration hash used to fill in the ERB
106
+ # template.
107
+ #
108
+ # @return [String] the content of the filled in template.
109
+ def vagrantfile_content(template, config)
110
+ renderer = ERB.new(File.read(template), 0, '-')
111
+
112
+ no_proxy = proxy_config(config)
113
+
114
+ # Agent nodes [Array<Hash>]
115
+ agent_nodes = vagrantfile_agents(config)
116
+ # Puppet Master nodes [Array<Hash>]
117
+ puppet_master_nodes = vagrantfile_puppet_masters(config)
118
+
119
+ # nodes is used by the Vagrantfile.erb template
120
+ nodes = [*puppet_master_nodes, *agent_nodes]
121
+ content = renderer.result(binding)
122
+ content
123
+ end
124
+
125
+ ##
126
+ # dump out the puppetmaster settings from the config.
127
+ def puppetmaster_settings(config)
128
+ log.debug "config['puppetmaster'] = \n" + \
129
+ config['puppetmaster'].pretty_inspect
130
+ config['puppetmaster']
131
+ end
132
+
133
+ # Constants used by the Vagrantfile.erb template.
134
+ DEFAULT_NO_PROXY = 'localhost,127.0.0.1'.freeze
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,172 @@
1
+ require 'rzo/logging'
2
+ require 'deep_merge'
3
+ module Rzo
4
+ class App
5
+ # The base class for subcommands
6
+ class Subcommand
7
+ include Logging
8
+ extend Logging
9
+ # The options hash injected from the application controller via the
10
+ # initialize method.
11
+ attr_reader :opts
12
+ # The Rizzo configuration after loading ~/.rizzo.json (--config).
13
+ # See #load_config!
14
+ attr_reader :config
15
+
16
+ ##
17
+ # Delegated method to mock with fixture data.
18
+ def self.load_rizzo_config(fpath)
19
+ config_file = Pathname.new(fpath).expand_path
20
+ raise ErrorAndExit, "Cannot read config file #{config_file}" unless config_file.readable?
21
+ config = JSON.parse(config_file.read)
22
+ log.debug "Loaded #{config_file}"
23
+ config
24
+ rescue JSON::ParserError => e
25
+ raise ErrorAndExit, "Could not parse rizzo config #{config_file} #{e.message}"
26
+ end
27
+
28
+ # Initialize a subcommand with options injected by the application
29
+ # controller.
30
+ #
31
+ # @param [Hash] opts the Options hash initialized by the Application
32
+ # controller.
33
+ def initialize(opts = {}, stdout = $stdout, stderr = $stderr)
34
+ @opts = opts
35
+ @stdout = stdout
36
+ @stderr = stderr
37
+ reset_logging!(opts)
38
+ end
39
+
40
+ ##
41
+ # Default run method. Override this method in a subcommand sub-class
42
+ #
43
+ # @return [Fixnum] the exit status of the subcommand.
44
+ def run
45
+ error "Implement the run method in subclass #{self.class}"
46
+ 1
47
+ end
48
+
49
+ private
50
+
51
+ ##
52
+ # Load rizzo configuration. Populate @config.
53
+ #
54
+ # Read rizzo configuration by looping through control repos and stopping
55
+ # at first match and merge on top of local, defaults (~/.rizzo.json)
56
+ def load_config!
57
+ config = load_rizzo_config(opts[:config])
58
+ validate_config(config)
59
+ repos = config['control_repos']
60
+ @config = load_repo_configs(config, repos)
61
+ debug "Merged configuration: \n#{JSON.pretty_generate(@config)}"
62
+ validate_forwarded_ports(@config)
63
+ validate_ip_addresses(@config)
64
+ @config
65
+ end
66
+
67
+ ##
68
+ # Given a list of repository paths, load .rizzo.json from the root of each
69
+ # repository and return the result merged onto config. The merging
70
+ # behavior is implemented by
71
+ # [deep_merge](http://www.rubydoc.info/gems/deep_merge/1.1.1)
72
+ #
73
+ # @param [Hash] config the starting config hash. Repo config maps will be
74
+ # merged on top of this starting map.
75
+ #
76
+ # @param [Array] repos the list of repositories to load .rizzo.json from.
77
+ #
78
+ # @return [Hash] the merged configuration hash.
79
+ def load_repo_configs(config = {}, repos = [])
80
+ repos.each_with_object(config.dup) do |repo, hsh|
81
+ fp = Pathname.new(repo).expand_path + '.rizzo.json'
82
+ if fp.readable?
83
+ hsh.deep_merge!(load_rizzo_config(fp))
84
+ else
85
+ log.debug "Skipped #{fp} (it is not readable)"
86
+ end
87
+ end
88
+ end
89
+
90
+ ##
91
+ # Basic validation of the configuration file content.
92
+ #
93
+ # @param [Hash] config the configuration map
94
+ def validate_config(config)
95
+ errors = []
96
+ errors.push 'control_repos key is not an Array' unless config['control_repos'].is_a?(Array)
97
+ errors.each { |l| log.error l }
98
+ raise ErrorAndExit, 'Errors found in config file. Cannot proceed.' unless errors.empty?
99
+ end
100
+
101
+ ##
102
+ # Check for duplicate forwarded host ports across all hosts and exit
103
+ # non-zero with an error message if found.
104
+ def validate_forwarded_ports(config)
105
+ host_ports = []
106
+ [*config['nodes']].each do |node|
107
+ [*node['forwarded_ports']].each do |hsh|
108
+ port = hsh['host'].to_i
109
+ raise_port_err(port, node['name']) if host_ports.include?(port)
110
+ host_ports.push(port)
111
+ end
112
+ end
113
+ log.debug "host_ports = #{host_ports}"
114
+ end
115
+
116
+ ##
117
+ # Check for duplicate forwarded host ports across all hosts and exit
118
+ # non-zero with an error message if found.
119
+ def validate_ip_addresses(config)
120
+ ips = []
121
+ [*config['nodes']].each do |node|
122
+ if ip = node['ip']
123
+ raise_ip_err(ip, node['name']) if ips.include?(ip)
124
+ ips.push(ip)
125
+ end
126
+ end
127
+ log.debug "ips = #{ips}"
128
+ end
129
+
130
+ ##
131
+ # Helper to raise a duplicate port error
132
+ def raise_port_err(port, node)
133
+ raise ErrorAndExit, "host port #{port} on node #{node} " \
134
+ 'is a duplicate. Ports must be unique. Check .rizzo.json ' \
135
+ 'files in each control repository for duplicate forwarded_ports entries.'
136
+ end
137
+
138
+ ##
139
+ # Helper to raise a duplicate port error
140
+ def raise_ip_err(ip, node)
141
+ raise ErrorAndExit, "host ip #{ip} on node #{node} " \
142
+ 'is a duplicate. IP addresses must be unique. Check .rizzo.json ' \
143
+ 'files in each control repository for duplicate ip entries'
144
+ end
145
+
146
+ ##
147
+ # Load the base configuration and return it as a hash. This is necessary
148
+ # to get access to the `'control_repos'` top level key, which is expected
149
+ # to be an Array of fully qualified paths to control repo base
150
+ # directories.
151
+ #
152
+ # @param [String] fpath The fully qualified path to the configuration file
153
+ # to load.
154
+ #
155
+ # @return [Hash] The configuration map
156
+ def load_rizzo_config(fpath)
157
+ self.class.load_rizzo_config(fpath)
158
+ end
159
+
160
+ ##
161
+ # Write a file by yielding a file descriptor to the passed block. In the
162
+ # case of opening a file, the FD will automatically be closed.
163
+ def write_file(filepath)
164
+ case filepath
165
+ when 'STDOUT' then yield @stdout
166
+ when 'STDERR' then yield @stderr
167
+ else File.open(filepath, 'w') { |fd| yield fd }
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,55 @@
1
+ # This file generated with Rizzo <%= Rzo.version %> on <%= timestamp %>
2
+ # https://github.com/ghoneycutt/rizzo
3
+ Vagrant.configure(2) do |config|
4
+ # use 'vagrant plugin install vagrant-proxyconf' to install
5
+ if Vagrant.has_plugin?('vagrant-proxyconf')
6
+ config.proxy.http = ENV['HTTP_PROXY'] if ENV['HTTP_PROXY']
7
+ config.proxy.https = ENV['HTTPS_PROXY'] if ENV['HTTPS_PROXY']
8
+ <%- if no_proxy -%>
9
+ config.proxy.no_proxy = <%= no_proxy.inspect %>
10
+ <%- end -%>
11
+ end
12
+ <%- nodes.each do |nc| -%>
13
+
14
+ config.vm.define <%= nc['name'].inspect %>, autostart: false do |cfg|
15
+ cfg.vm.box = <%= nc['box'].inspect %>
16
+ cfg.vm.box_url = <%= nc['box_url'].inspect %>
17
+ cfg.vm.box_download_checksum = <%= nc['box_download_checksum'].inspect %>
18
+ cfg.vm.box_download_checksum_type = <%= nc['box_download_checksum_type'].inspect %>
19
+ cfg.vm.provider :virtualbox do |vb|
20
+ vb.customize ['modifyvm', :id, '--memory', <%= nc['memory'].inspect %>]
21
+ end
22
+ cfg.vm.hostname = <%= nc['hostname'].inspect %>
23
+ cfg.vm.network 'private_network',
24
+ ip: <%= nc['ip'].inspect %>,
25
+ netmask: <%= nc['netmask'].inspect %>
26
+ <%- [*nc['forwarded_ports']].each do |forwarded_port| -%>
27
+ cfg.vm.network 'forwarded_port',
28
+ guest: <%= forwarded_port['guest'].inspect %>,
29
+ host: <%= forwarded_port['host'].inspect %>
30
+ <%- end -%>
31
+ <%- synced_folders = nc['synced_folders'] || {} -%>
32
+ <%- synced_folders.each_pair do |k, v| -%>
33
+ cfg.vm.synced_folder <%= v['local'].inspect %>, <%= k.inspect %>,
34
+ owner: <%= v['owner'].inspect %>, group: <%= v['group'].inspect %>
35
+ <%- end -%>
36
+ <%- if nc['bootstrap_repo_path'] -%>
37
+ config.vm.synced_folder <%= nc['bootstrap_repo_path'].inspect %>,
38
+ <%= nc['bootstrap_guest_path'].inspect %>,
39
+ owner: 'vagrant', group: 'root'
40
+ <%- if nc[:puppetmaster] -%>
41
+ config.vm.provision 'shell', inline: <%= "echo 'modulepath = #{nc['modulepath'].join(':')}' > #{nc['bootstrap_guest_path']}/environment.conf".inspect %>
42
+ <%- end -%>
43
+ config.vm.provision 'shell', inline: <%= "/bin/bash #{nc['bootstrap_guest_path']}/#{nc['bootstrap_script_path']} #{nc['bootstrap_script_args']}".inspect %>
44
+ <%- if nc['update_packages'] -%>
45
+ config.vm.provision 'shell', inline: <%= nc['update_packages_command'].inspect %>
46
+ <%- end -%>
47
+ <%- if nc['shutdown'] -%>
48
+ config.vm.provision 'shell', inline: <%= nc['shutdown_command'].inspect %>
49
+ <%- end -%>
50
+ <%- end -%>
51
+ end
52
+ <%- end -%>
53
+ end
54
+ # -*- mode: ruby -*-
55
+ # vim:ft=ruby
data/lib/rzo/app.rb ADDED
@@ -0,0 +1,92 @@
1
+ require 'rzo'
2
+ require 'rzo/logging'
3
+ require 'rzo/option_parsing'
4
+ require 'rzo/app/config'
5
+ require 'rzo/app/generate'
6
+ require 'json'
7
+
8
+ module Rzo
9
+ ##
10
+ # The application controller. An instance of this class models the
11
+ # application lifecycle. Input configuration follows the 12 factor model.
12
+ #
13
+ # The general lifecycle is:
14
+ #
15
+ # * app = App.new()
16
+ # * app.parse_options!
17
+ # * app.run
18
+ class App
19
+ include Rzo::Logging
20
+ include Rzo::OptionParsing
21
+
22
+ ##
23
+ # Exception used to exit the app from a subcommand. Caught by the main run
24
+ # method in the app controller
25
+ class ErrorAndExit < StandardError
26
+ attr_accessor :exit_status
27
+ def initialize(message = nil, exit_status = 1)
28
+ super(message)
29
+ self.exit_status = exit_status
30
+ end
31
+ end
32
+
33
+ ##
34
+ # @param [Array] argv The argument vector, passed to the option parser.
35
+ #
36
+ # @param [Hash] env The environment hash, passed to the option parser to
37
+ # supply defaults not specified on the command line argument vector.
38
+ #
39
+ # @return [App] the application instance.
40
+ def initialize(argv = ARGV.dup, env = ENV.to_hash, stdout = $stdout, stderr = $stderr)
41
+ @argv = argv
42
+ @env = env
43
+ @stdout = stdout
44
+ @stderr = stderr
45
+ reset!
46
+ end
47
+
48
+ ##
49
+ # Reset all state associated with this application instance.
50
+ def reset!
51
+ reset_options!
52
+ reset_logging!(opts)
53
+ @api = nil
54
+ end
55
+
56
+ ##
57
+ # Accessor to Subcommand::Generate
58
+ def generate
59
+ @generate ||= Generate.new(opts, @stdout, @stderr)
60
+ end
61
+
62
+ ##
63
+ # Override this later to allow trollop to write to an intercepted file
64
+ # descriptor for testing. This will avoid trollop's behavior of calling
65
+ # exit()
66
+ def educate
67
+ Trollop.educate
68
+ end
69
+
70
+ ##
71
+ # The main application run method
72
+ #
73
+ # @return [Fixnum] the system exit code
74
+ #
75
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
76
+ def run
77
+ case opts[:subcommand]
78
+ when 'config'
79
+ Config.new(opts, @stdout, @stderr).run
80
+ when 'generate'
81
+ generate.run
82
+ else
83
+ educate
84
+ end
85
+ rescue ErrorAndExit => e
86
+ log.error e.message
87
+ e.backtrace.each { |l| log.debug(l) }
88
+ e.exit_status
89
+ end
90
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
91
+ end
92
+ end
@@ -0,0 +1,144 @@
1
+ require 'logger'
2
+ require 'stringio'
3
+ require 'syslog/logger'
4
+ module Rzo
5
+ ##
6
+ # Support module to mix into a class for consistent logging behavior.
7
+ module Logging
8
+ ##
9
+ # Reset the global logger instance and return it as an object.
10
+ #
11
+ # @return [Logger] initialized logging instance
12
+ def self.reset_logging!(opts)
13
+ logger = opts[:syslog] ? syslog_logger : stream_logger(opts)
14
+ @log = logger
15
+ end
16
+
17
+ ##
18
+ # Return a new Syslog::Logger instance configured for syslog output
19
+ def self.syslog_logger
20
+ # Use the daemon facility, matching Puppet behavior.
21
+ Syslog::Logger.new('rzo', Syslog::LOG_DAEMON)
22
+ end
23
+
24
+ ##
25
+ # Return a new Logger instance configured for file output
26
+ def self.stream_logger(opts)
27
+ out = map_file_option(opts[:logto])
28
+ logger = Logger.new(out)
29
+ logger.level = Logger::WARN
30
+ logger.level = Logger::INFO if opts[:verbose]
31
+ logger.level = Logger::DEBUG if opts[:debug]
32
+ logger
33
+ end
34
+
35
+ ##
36
+ # Logging is handled centrally, the helper methods will delegate to the
37
+ # centrally configured logging instance.
38
+ def self.log
39
+ @log || reset_logging!(opts)
40
+ end
41
+
42
+ ##
43
+ # Map a file option to STDOUT, STDERR or a fully qualified file path.
44
+ #
45
+ # @param [String] filepath A relative or fully qualified file path, or the
46
+ # keyword strings 'STDOUT' or 'STDERR'
47
+ #
48
+ # @return [String] file path or $stdout or $sederr
49
+ def self.map_file_option(filepath)
50
+ case filepath
51
+ when 'STDOUT' then $stdout
52
+ when 'STDERR' then $stderr
53
+ when 'STDIN' then $stdin
54
+ when 'STRING' then StringIO.new
55
+ else File.expand_path(filepath)
56
+ end
57
+ end
58
+
59
+ def map_file_option(filepath)
60
+ ::Rzo::Logging.map_file_option(filepath)
61
+ end
62
+
63
+ def log
64
+ ::Rzo::Logging.log
65
+ end
66
+
67
+ ##
68
+ # Reset the logging system, requires command line options to have been
69
+ # parsed.
70
+ #
71
+ # @param [Hash<Symbol, String>] opts Options hash, passed to the support module
72
+ def reset_logging!(opts)
73
+ ::Rzo::Logging.reset_logging!(opts)
74
+ end
75
+
76
+ ##
77
+ # Logs a message at the fatal (syslog err) log level
78
+ def fatal(msg)
79
+ log.fatal msg
80
+ end
81
+
82
+ ##
83
+ # Logs a message at the error (syslog warning) log level.
84
+ # i.e. May indicate that an error will occur if action is not taken.
85
+ # e.g. A non-root file system has only 2GB remaining.
86
+ def error(msg)
87
+ log.error msg
88
+ end
89
+
90
+ ##
91
+ # Logs a message at the warn (syslog notice) log level.
92
+ # e.g. Events that are unusual, but not error conditions.
93
+ def warn(msg)
94
+ log.warn msg
95
+ end
96
+
97
+ ##
98
+ # Logs a message at the info (syslog info) log level
99
+ # i.e. Normal operational messages that require no action.
100
+ # e.g. An application has started, paused or ended successfully.
101
+ def info(msg)
102
+ log.info msg
103
+ end
104
+
105
+ ##
106
+ # Logs a message at the debug (syslog debug) log level
107
+ # i.e. Information useful to developers for debugging the application.
108
+ def debug(msg)
109
+ log.debug msg
110
+ end
111
+
112
+ ##
113
+ # Helper method to write output, used for stubbing out the tests.
114
+ #
115
+ # @param [String, IO] output the output path or a IO stream
116
+ def write_output(str, output)
117
+ if output.is_a?(IO)
118
+ output.puts(str)
119
+ else
120
+ File.open(output, 'w+') { |f| f.puts(str) }
121
+ end
122
+ end
123
+
124
+ ##
125
+ # Helper method to read from STDIN, or a file and execute an arbitrary block
126
+ # of code. A block must be passed which will recieve an IO object in the
127
+ # event input is a readable file path.
128
+ def input_stream(input)
129
+ if input.is_a?(IO)
130
+ yield input
131
+ else
132
+ File.open(input, 'r') { |stream| yield stream }
133
+ end
134
+ end
135
+
136
+ ##
137
+ # Alternative to puts, writes output to STDERR by default and logs at level
138
+ # info.
139
+ def say(msg)
140
+ log.info(msg)
141
+ @stderr.puts(msg)
142
+ end
143
+ end
144
+ end