octocatalog-diff 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 86467134c489b13251760bd9ffd23caced1c5222
4
- data.tar.gz: 8260b43c4e844b2224dd491018feb8a1da65c4ed
3
+ metadata.gz: 75c0856d744b0c909315a69aceb6e2d5e416991b
4
+ data.tar.gz: 127ac1617cc437a73d716ff68a0eea0623e62c8b
5
5
  SHA512:
6
- metadata.gz: fedd421520a95d4147878d50510d7f2f149cfcba0933c75d7f88c5f3efbeb45f6105b9c5bab53cbc46cf7234db1987cba0b9bdc327cd0b418ce8f69365723282
7
- data.tar.gz: 1bc344f71b92ef5154253e4b115f7916e59a9ee49c21c7344adb80ad7dd97d2f164fb951016e8a22d95ba564eab1dbc27a9c1b0cff8a014d327bfde881fb5d21
6
+ metadata.gz: a4a873ed14e553d8362267e88fdac5eeaf46e5929275601871048959805f93515af9f8d8152115e49f1c15ddf17fc38fa70158b1e001be71d20a0646f05c422e
7
+ data.tar.gz: 7c3f9a881d1e2b9d8669c96d6ffbb9953a25b8ff2c63f2a9c3151201c09be9ee95c6182b040370b08a54720c830e98f701cbe3f903c7b876ae80ec140b08a3b0
data/.version CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0
data/doc/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@
8
8
  </tr>
9
9
  </thead><tbody>
10
10
  <tr valign=top>
11
+ <td>1.2.0</td>
12
+ <td>2017-05-18</td>
13
+ <td>
14
+ <li><a href="https://github.com/github/octocatalog-diff/pull/112">#112</a>: Split arguments added for ENC</li>
15
+ <li><a href="https://github.com/github/octocatalog-diff/pull/113">#113</a>: (Enhancement) Override facts and ENC parameters using regular expressions</li>
16
+ <li><a href="https://github.com/github/octocatalog-diff/pull/103">#111</a>: Simplify parallel processing to solve some intermittent failures</li>
17
+ <li><a href="https://github.com/github/octocatalog-diff/pull/110">#110</a>: Ruby 2.4 compatibility</li>
18
+ </td>
19
+ </tr>
20
+ <tr valign=top>
11
21
  <td>1.1.0</td>
12
22
  <td>2017-05-08</td>
13
23
  <td>
@@ -65,3 +65,16 @@ The following data types in parentheses are supported:
65
65
  | `(json)` | Treat the input as a JSON string (calls `JSON.parse` in ruby) |
66
66
  | `(boolean)` | Treat the input as a boolean -- it must be `true` or `false`, case-insensitive |
67
67
  | `(nil)` | Ignore any characters after `(nil)` and deletes the fact if the fact exists |
68
+
69
+ ## Regular expressions
70
+
71
+ If you wish to match multiple facts by pattern, specify the regular expression in place of the key name. For example:
72
+
73
+ ```
74
+ octocatalog-diff -n some-node.example.com -f master -t master \
75
+ --to-fact-override /^ipaddress/=10.11.12.13
76
+ ```
77
+
78
+ In this example, `$::ipaddress`, `$::ipaddress_eth0`, `$::ipaddress_bond0`, and any other facts starting with "ipaddress" would be overridden. However, a fact named `$::additional_ipaddress` would not be overridden, because it does not match the regular expression.
79
+
80
+ Please note that you cannot *add* a fact with a regular expression -- when using regular expressions you can only modify or delete facts.
@@ -22,7 +22,7 @@ describe 'whatever behavior' do
22
22
  # @result[:logs] is a String containing everything printed to STDERR (Logger)
23
23
  # @result[:output] is a String containing everything printed to STDOUT
24
24
  # @result[:diffs] is an Array of differences
25
- # @result[:exitcode] is a Fixnum representing the exit code: 0 = no changes, 1 = failure, 2 = success, with changes
25
+ # @result[:exitcode] is an Integer representing the exit code: 0 = no changes, 1 = failure, 2 = success, with changes
26
26
  # @result[:exception] contains any exception that was thrown
27
27
  end
28
28
 
@@ -46,7 +46,7 @@ describe 'whatever behavior' do
46
46
  # @result[:logs] is a String containing everything printed to STDERR (Logger)
47
47
  # @result[:output] is a String containing everything printed to STDOUT
48
48
  # @result[:diffs] is an Array of differences
49
- # @result[:exitcode] is a Fixnum representing the exit code: 0 = no changes, 1 = failure, 2 = success, with changes
49
+ # @result[:exitcode] is an Integer representing the exit code: 0 = no changes, 1 = failure, 2 = success, with changes
50
50
  # @result[:exception] contains any exception that was thrown
51
51
  end
52
52
 
data/doc/requirements.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  To run `octocatalog-diff` you will need these basics:
4
4
 
5
- - Ruby 2.0 or higher
5
+ - Ruby 2.0 through 2.4 (we test octocatalog-diff with Ruby 2.0, 2.1, 2.2, 2.3, and 2.4)
6
6
  - Mac OS, Linux, or other Unix-line operating system (Windows is not supported)
7
7
  - Ability to install gems, e.g. with [rbenv](https://github.com/rbenv/rbenv) or [rvm](https://rvm.io/), or root privileges to install into the system Ruby
8
- - Puppet agent for [Linux](https://docs.puppet.com/puppet/latest/reference/install_linux.html) or [Mac OS X](https://docs.puppet.com/puppet/latest/reference/install_osx.html), or installed as a gem
8
+ - Puppet agent for [Linux](https://docs.puppet.com/puppet/latest/reference/install_linux.html) or [Mac OS X](https://docs.puppet.com/puppet/latest/reference/install_osx.html), or installed as a gem (we support Puppet 3.8.7 and all versions of Puppet 4.x)
9
9
 
10
10
  We recommend that you also have the following to get the most out of `octocatalog-diff`, but these are not absolute requirements:
11
11
 
@@ -13,7 +13,8 @@ module OctocatalogDiff
13
13
  # Constructor: Accepts a key and value.
14
14
  # @param input [Hash] Must contain :key and :value
15
15
  def initialize(input)
16
- @key = input.fetch(:key)
16
+ key = input.fetch(:key)
17
+ @key = key =~ %r{\A/(.+)/\Z} ? Regexp.new(Regexp.last_match(1)) : key
17
18
  @value = parsed_value(input.fetch(:value))
18
19
  end
19
20
 
@@ -29,6 +30,7 @@ module OctocatalogDiff
29
30
  # If input is not a string, we can still construct the object if the key is given.
30
31
  # That input would come directly from code and not from the command line, since inputs
31
32
  # from the command line are always strings.
33
+ # Also support regular expressions for the key name, if delimited by //.
32
34
  if key.nil? && input.is_a?(String)
33
35
  unless input.include?('=')
34
36
  raise ArgumentError, "Fact override '#{input}' is not in 'key=(data type)value' format"
@@ -72,9 +74,9 @@ module OctocatalogDiff
72
74
  return value if datatype == 'string'
73
75
  return parse_json(value) if datatype == 'json'
74
76
  return nil if datatype == 'nil'
75
- if datatype == 'fixnum'
77
+ if datatype == 'fixnum' || datatype == 'integer'
76
78
  return Regexp.last_match(1).to_i if value =~ /^(-?\d+)$/
77
- raise ArgumentError, "Illegal fixnum '#{value}'"
79
+ raise ArgumentError, "Illegal integer '#{value}'"
78
80
  end
79
81
  if datatype == 'float'
80
82
  return Regexp.last_match(1).to_f if value =~ /^(-?\d*\.\d+)$/
@@ -10,7 +10,7 @@ module OctocatalogDiff
10
10
  # @param options [Hash] Options hash:
11
11
  # :path [String] => Directory to bootstrap
12
12
  # :bootstrap_script [String] => Bootstrap script, relative to directory
13
- # @return [Hash] => [Fixnum] :status_code, [String] :output
13
+ # @return [Hash] => [Integer] :status_code, [String] :output
14
14
  def self.bootstrap(options = {})
15
15
  # Options validation
16
16
  unless options[:path].is_a?(String)
@@ -530,7 +530,7 @@ module OctocatalogDiff
530
530
 
531
531
  # Added a new key that points to some kind of data structure that we know how
532
532
  # to handle.
533
- if obj[1] =~ /^(.+)\f([^\f]+)$/ && [String, Fixnum, Float, TrueClass, FalseClass, Array, Hash].include?(obj[2].class)
533
+ if obj[1] =~ /^(.+)\f([^\f]+)$/ && [String, Integer, Float, TrueClass, FalseClass, Array, Hash].include?(obj[2].class)
534
534
  hashdiff_add_remove.add(obj[1])
535
535
  next
536
536
  end
@@ -255,7 +255,7 @@ module OctocatalogDiff
255
255
  # Get the diff of two long strings. Call the 'diffy' gem for this.
256
256
  # @param string1 [String] First string (-)
257
257
  # @param string2 [String] Second string (+)
258
- # @param depth [Fixnum] Depth, for correct indentation
258
+ # @param depth [Integer] Depth, for correct indentation
259
259
  # @return Array<String> Displayable result
260
260
  def self.diff_two_strings_with_diffy(string1, string2, depth)
261
261
  # Single line strings?
@@ -324,8 +324,8 @@ module OctocatalogDiff
324
324
  # Get the diff of two hashes. Call the 'diffy' gem for this.
325
325
  # @param hash1 [Hash] First hash (-)
326
326
  # @param hash1 [Hash] Second hash (+)
327
- # @param depth [Fixnum] Depth, for correct indentation
328
- # @param limit [Fixnum] Maximum string length
327
+ # @param depth [Integer] Depth, for correct indentation
328
+ # @param limit [Integer] Maximum string length
329
329
  # @param strip_diff [Boolean] Strip leading +/-/" "
330
330
  # @return [Array<String>] Displayable result
331
331
  def self.diff_two_hashes_with_diffy(opts = {})
@@ -358,7 +358,7 @@ module OctocatalogDiff
358
358
  end
359
359
 
360
360
  # Special case: addition only, no truncation
361
- # @param depth [Fixnum] Depth, for correct indentation
361
+ # @param depth [Integer] Depth, for correct indentation
362
362
  # @param hash [Hash] Added object
363
363
  # @return [Array<String>] Displayable result
364
364
  def self.addition_only_no_truncation(depth, hash)
@@ -383,7 +383,7 @@ module OctocatalogDiff
383
383
 
384
384
  # Limit length of a string
385
385
  # @param str [String] String
386
- # @param limit [Fixnum] Limit (0=unlimited)
386
+ # @param limit [Integer] Limit (0=unlimited)
387
387
  # @return [String] Truncated string
388
388
  def self.truncate_string(str, limit)
389
389
  return str if limit.nil? || str.length <= limit
@@ -392,7 +392,7 @@ module OctocatalogDiff
392
392
 
393
393
  # Get the diff between two hashes. This is recursive-aware.
394
394
  # @param obj [diff object] diff object
395
- # @param depth [Fixnum] Depth of nesting, used for indentation
395
+ # @param depth [Integer] Depth of nesting, used for indentation
396
396
  # @return Array<String> Printable diff outputs
397
397
  def self.hash_diff(obj, depth, key_in, nested = false)
398
398
  result = []
@@ -417,7 +417,7 @@ module OctocatalogDiff
417
417
  end
418
418
 
419
419
  # Get the diff between two arbitrary objects
420
- # @param depth [Fixnum] Depth of nesting, used for indentation
420
+ # @param depth [Integer] Depth of nesting, used for indentation
421
421
  # @param old_obj [?] Old object
422
422
  # @param new_obj [?] New object
423
423
  # @return Array<String> Diff output
@@ -432,12 +432,31 @@ module OctocatalogDiff
432
432
 
433
433
  # Utility Method!
434
434
  # Indent a given text string with a certain number of spaces
435
- # @param spaces [Fixnum] Number of spaces
435
+ # @param spaces [Integer] Number of spaces
436
436
  # @param text [String] Text
437
437
  def self.left_pad(spaces, text = '')
438
438
  [' ' * spaces, text].join('')
439
439
  end
440
440
 
441
+ # Utility Method!
442
+ # Harmonize equivalent class names for comparison purposes.
443
+ # @param class_name [String] Class name as input
444
+ # @return [String] Class name as output
445
+ def self.class_name_for_diffy(class_name)
446
+ return 'Integer' if class_name == 'Fixnum'
447
+ class_name
448
+ end
449
+
450
+ # Utility Method!
451
+ # `is_a?(class)` only allows one method, but this uses an array
452
+ # @param object [?] Object to consider
453
+ # @param classes [Array] Classes to determine if object is a member of
454
+ # @return [Boolean] True if object is_a any of the classes, false otherwise
455
+ def self.object_is_any_of?(object, classes)
456
+ classes.each { |clazz| return true if object.is_a? clazz }
457
+ false
458
+ end
459
+
441
460
  # Utility Method!
442
461
  # Given an arbitrary object, convert it into a string for use by 'diffy'.
443
462
  # This basically exists so we can do something prettier than just calling .inspect or .to_s
@@ -445,10 +464,10 @@ module OctocatalogDiff
445
464
  # @param obj [?] Object to be stringified
446
465
  # @return [String] String representation of object for diffy
447
466
  def self.stringify_for_diffy(obj)
448
- return JSON.pretty_generate(obj) if [Hash, Array].include?(obj.class)
467
+ return JSON.pretty_generate(obj) if object_is_any_of?(obj, [Hash, Array])
449
468
  return '""' if obj.is_a?(String) && obj == ''
450
- return obj if [String, Fixnum, Float].include?(obj.class)
451
- "#{obj.class}: #{obj.inspect}"
469
+ return obj if object_is_any_of?(obj, [String, Fixnum, Integer, Float])
470
+ "#{class_name_for_diffy(obj.class)}: #{obj.inspect}"
452
471
  end
453
472
 
454
473
  # Utility Method!
@@ -512,8 +531,8 @@ module OctocatalogDiff
512
531
  return ['""', 'undef'] if obj2.nil?
513
532
 
514
533
  # If one is an integer and the other is a string
515
- return [obj1, "\"#{obj2}\""] if obj1.is_a?(Fixnum) && obj2.is_a?(String)
516
- return ["\"#{obj1}\"", obj2] if obj1.is_a?(String) && obj2.is_a?(Fixnum)
534
+ return [obj1, "\"#{obj2}\""] if obj1.is_a?(Integer) && obj2.is_a?(String)
535
+ return ["\"#{obj1}\"", obj2] if obj1.is_a?(String) && obj2.is_a?(Integer)
517
536
 
518
537
  # True and false
519
538
  return [obj1, "\"#{obj2}\""] if obj1.is_a?(TrueClass) && obj2.is_a?(String)
@@ -74,8 +74,11 @@ module OctocatalogDiff
74
74
  if result.status
75
75
  logger.debug("Success bootstrap_directory for #{result.args[:tag]}")
76
76
  else
77
+ # Believed to be a bug condition, since error should have already been raised if this happens.
78
+ # :nocov:
77
79
  errmsg = "Failed bootstrap_directory for #{result.args[:tag]}: #{result.exception.class} #{result.exception.message}"
78
80
  raise OctocatalogDiff::Errors::BootstrapError, errmsg
81
+ # :nocov:
79
82
  end
80
83
  end
81
84
  end
@@ -21,7 +21,7 @@ module OctocatalogDiff
21
21
  # Constructor
22
22
  # Options for constructor:
23
23
  # :puppetdb_url [String] PuppetDB Server URLs
24
- # :puppetdb_server_url_timeout [Fixnum] Timeout (seconds) for puppetdb.conf
24
+ # :puppetdb_server_url_timeout [Integer] Timeout (seconds) for puppetdb.conf
25
25
  # :facts [OctocatalogDiff::Facts] Facts object
26
26
  # :fact_file [String] File from which to read facts
27
27
  # :node [String] Node name
@@ -99,14 +99,14 @@ module OctocatalogDiff
99
99
 
100
100
  # Install puppetdb.conf file in temporary directory
101
101
  # @param server_urls [String] String for server_urls in puppetdb.conf
102
- # @param server_url_timeout [Fixnum] Value for server_url_timeout in puppetdb.conf
102
+ # @param server_url_timeout [Integer] Value for server_url_timeout in puppetdb.conf
103
103
  def install_puppetdb_conf(logger, server_urls, server_url_timeout = 30)
104
104
  unless server_urls.is_a?(String)
105
105
  raise ArgumentError, "server_urls must be a string, got a: #{server_urls.class}"
106
106
  end
107
107
 
108
108
  server_url_timeout ||= 30 # If called with nil argument, supply default
109
- unless server_url_timeout.is_a?(Fixnum)
109
+ unless server_url_timeout.is_a?(Integer)
110
110
  raise ArgumentError, "server_url_timeout must be a fixnum, got a: #{server_url_timeout.class}"
111
111
  end
112
112
 
@@ -163,9 +163,12 @@ module OctocatalogDiff
163
163
 
164
164
  if options[:fact_override].is_a?(Array)
165
165
  options[:fact_override].each do |override|
166
- old_value = facts.fact(override.key)
167
- facts.override(override.key, override.value)
168
- logger.debug("Override #{override.key} from #{old_value.inspect} to #{override.value.inspect}")
166
+ keys = override.key.is_a?(Regexp) ? facts.matching(override.key) : [override.key]
167
+ keys.each do |key|
168
+ old_value = facts.fact(key)
169
+ facts.override(key, override.value)
170
+ logger.debug("Override #{key} from #{old_value.inspect} to #{override.value.inspect}")
171
+ end
169
172
  end
170
173
  end
171
174
 
@@ -68,7 +68,8 @@ module OctocatalogDiff
68
68
  # enc?
69
69
  if @options[:enc]
70
70
  raise Errno::ENOENT, "Did not find ENC as expected at #{@options[:enc]}" unless File.file?(@options[:enc])
71
- cmdline << "--node_terminus=exec --external_nodes=#{Shellwords.escape(@options[:enc])}"
71
+ cmdline << '--node_terminus=exec'
72
+ cmdline << "--external_nodes=#{Shellwords.escape(@options[:enc])}"
72
73
  end
73
74
 
74
75
  # Future parser?
@@ -167,7 +168,7 @@ module OctocatalogDiff
167
168
  # the index.
168
169
  # @param cmdline [Array] Existing command line
169
170
  # @param key [String] Key to look up
170
- # @return [Fixnum] Index of where key is defined (nil if undefined)
171
+ # @return [Integer] Index of where key is defined (nil if undefined)
171
172
  def key_position(cmdline, key)
172
173
  cmdline.index { |x| x == "--#{key}" || x =~ /\A--#{key}=/ }
173
174
  end
@@ -65,8 +65,11 @@ module OctocatalogDiff
65
65
  return unless @options[:enc_override].is_a?(Array) && @options[:enc_override].any?
66
66
  content_structure = YAML.load(content)
67
67
  @options[:enc_override].each do |x|
68
- merge_enc_param(content_structure, x.key, x.value)
69
- logger.debug "ENC override: #{x.key} #{x.value.nil? ? 'DELETED' : '= ' + x.value.inspect}"
68
+ keys = x.key.is_a?(Regexp) ? content_structure.keys.select { |y| x.key.match(y) } : [x.key]
69
+ keys.each do |key|
70
+ merge_enc_param(content_structure, key, x.value)
71
+ logger.debug "ENC override: #{key} #{x.value.nil? ? 'DELETED' : '= ' + x.value.inspect}"
72
+ end
70
73
  end
71
74
  @content = content_structure.to_yaml
72
75
  end
@@ -86,7 +86,12 @@ module OctocatalogDiff
86
86
  resources.map! do |resource|
87
87
  if resource_convertible?(resource)
88
88
  path = file_path(resource['parameters']['source'], modulepaths)
89
- raise Errno::ENOENT, "Unable to resolve '#{resource['parameters']['source']}'!" if path.nil?
89
+ if path.nil?
90
+ # Pass this through as a wrapped exception, because it's more likely to be something wrong
91
+ # in the catalog itself than it is to be a broken setup of octocatalog-diff.
92
+ message = "Errno::ENOENT: Unable to resolve '#{resource['parameters']['source']}'!"
93
+ raise OctocatalogDiff::Errors::CatalogError, message
94
+ end
90
95
 
91
96
  if File.file?(path)
92
97
  # If the file is found, read its content. If the content is all ASCII, substitute it into
@@ -22,7 +22,7 @@ module OctocatalogDiff
22
22
  # @param :node [String] REQUIRED: Node name
23
23
  # @param :basedir [String] Directory in which to compile the catalog
24
24
  # @param :pass_env_vars [Array<String>] Environment variables to pass when compiling catalog
25
- # @param :retry_failed_catalog [Fixnum] Number of retries if a catalog compilation fails
25
+ # @param :retry_failed_catalog [Integer] Number of retries if a catalog compilation fails
26
26
  # @param :tag [String] For display purposes, the catalog being compiled
27
27
  # @param :puppet_binary [String] Full path to Puppet
28
28
  # @param :puppet_version [String] Puppet version (optional; if not supplied, it is calculated)
@@ -15,7 +15,7 @@ module OctocatalogDiff
15
15
 
16
16
  # Constructor - See OctocatalogDiff::PuppetDB for additional parameters
17
17
  # @param :node [String] Node name
18
- # @param :retry [Fixnum] Number of retries, if fetch fails
18
+ # @param :retry [Integer] Number of retries, if fetch fails
19
19
  def initialize(options)
20
20
  raise ArgumentError, 'Hash of options must be passed to OctocatalogDiff::Catalog::PuppetDB' unless options.is_a?(Hash)
21
21
  raise ArgumentError, 'node must be a non-empty string' unless options[:node].is_a?(String) && options[:node] != ''
@@ -22,17 +22,17 @@ module OctocatalogDiff
22
22
 
23
23
  # Constructor
24
24
  # @param :node [String] Node name
25
- # @param :retry_failed_catalog [Fixnum] Number of retries, if fetch fails
25
+ # @param :retry_failed_catalog [Integer] Number of retries, if fetch fails
26
26
  # @param :branch [String] Environment to fetch from Puppet Master
27
27
  # @param :puppet_master [String] Puppet server and port number (assumed to be DEFAULT_PUPPET_PORT_NUMBER if not given)
28
- # @param :puppet_master_api_version [Fixnum] Puppet server API (default DEFAULT_PUPPET_SERVER_API)
28
+ # @param :puppet_master_api_version [Integer] Puppet server API (default DEFAULT_PUPPET_SERVER_API)
29
29
  # @param :puppet_master_ssl_ca [String] Path to file used to sign puppet master's certificate
30
30
  # @param :puppet_master_ssl_verify [Boolean] Override the CA verification setting guessed from parameters
31
31
  # @param :puppet_master_ssl_client_pem [String] PEM-encoded client key and certificate
32
32
  # @param :puppet_master_ssl_client_p12 [String] pkcs12-encoded client key and certificate
33
33
  # @param :puppet_master_ssl_client_password [String] Path to file containing password for SSL client key (any format)
34
34
  # @param :puppet_master_ssl_client_auth [Boolean] Override the client-auth that is guessed from parameters
35
- # @param :timeout [Fixnum] Connection timeout for Puppet master (default=PUPPET_MASTER_TIMEOUT seconds)
35
+ # @param :timeout [Integer] Connection timeout for Puppet master (default=PUPPET_MASTER_TIMEOUT seconds)
36
36
  def initialize(options)
37
37
  raise ArgumentError, 'Hash of options must be passed to OctocatalogDiff::Catalog::PuppetMaster' unless options.is_a?(Hash)
38
38
  raise ArgumentError, 'node must be a non-empty string' unless options[:node].is_a?(String) && options[:node] != ''
@@ -50,7 +50,7 @@ module OctocatalogDiff
50
50
  # @param argv [Array] Use specified arguments (defaults to ARGV)
51
51
  # @param logger [Logger] Logger object
52
52
  # @param opts [Hash] Additional options
53
- # @return [Fixnum] Exit code: 0=no diffs, 1=something went wrong, 2=worked but there are diffs
53
+ # @return [Integer] Exit code: 0=no diffs, 1=something went wrong, 2=worked but there are diffs
54
54
  def self.cli(argv = ARGV, logger = Logger.new(STDERR), opts = {})
55
55
  # Save a copy of argv to print out later in debugging
56
56
  argv_save = argv.dup
@@ -14,7 +14,7 @@ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_timeout) do
14
14
  cli_name: 'puppet-master-timeout',
15
15
  option_name: 'puppet_master_timeout',
16
16
  desc: 'Puppet Master catalog retrieval timeout in seconds',
17
- validator: ->(x) { x.to_i > 0 || raise(ArgumentError, 'Specify timeout as a integer greater than 0') },
17
+ validator: ->(x) { x.to_i > 0 || raise(ArgumentError, 'Specify timeout as an integer greater than 0') },
18
18
  translator: ->(x) { x.to_i }
19
19
  )
20
20
  end
@@ -120,5 +120,12 @@ module OctocatalogDiff
120
120
  @facts['values'][key] = value
121
121
  end
122
122
  end
123
+
124
+ # Find all facts matching a particular pattern
125
+ # @param regex [Regexp] Regular expression to match
126
+ # @return [Array<String>] Facts that match the regexp
127
+ def matching(regex)
128
+ @facts['values'].keys.select { |fact| regex.match(fact) }
129
+ end
123
130
  end
124
131
  end
@@ -17,7 +17,7 @@ module OctocatalogDiff
17
17
 
18
18
  # Retrieve facts from PuppetDB for a specified node.
19
19
  # @param :puppetdb_url [String|Array] => URL to PuppetDB
20
- # @param :retry [Fixnum] => Retry after timeout (default 0 retries, can be more)
20
+ # @param :retry [Integer] => Retry after timeout (default 0 retries, can be more)
21
21
  # @param node [String] Node name. (REQUIRED for PuppetDB fact source)
22
22
  # @return [Hash] Facts
23
23
  def self.fact_retriever(options = {}, node)
@@ -38,7 +38,7 @@ module OctocatalogDiff
38
38
  # Supported arguments:
39
39
  # @param :puppetdb_url [String or Array<String>] PuppetDB URL(s) to try in random order
40
40
  # @param :puppetdb_host [String] PuppetDB hostname, when constructing a URL
41
- # @param :puppetdb_port [Fixnum] Port number, defaults to 8080 (non-SSL) or 8081 (SSL)
41
+ # @param :puppetdb_port [Integer] Port number, defaults to 8080 (non-SSL) or 8081 (SSL)
42
42
  # @param :puppetdb_ssl [Boolean] defaults to true, because you should use SSL
43
43
  # @param :puppetdb_ssl_ca [String] Path to file containing CA certificate
44
44
  # @param :puppetdb_ssl_verify [Boolean] Override the CA verification setting guessed from parameters
@@ -46,7 +46,7 @@ module OctocatalogDiff
46
46
  # @param :puppetdb_ssl_client_p12 [String] pkcs12-encoded client key and certificate
47
47
  # @param :puppetdb_ssl_client_password [String] Path to file containing password for SSL client key (any format)
48
48
  # @param :puppetdb_ssl_client_auth [Boolean] Override the client-auth that is guessed from parameters
49
- # @param :timeout [Fixnum] Connection timeout for PuppetDB (default=10)
49
+ # @param :timeout [Integer] Connection timeout for PuppetDB (default=10)
50
50
  def initialize(options = {})
51
51
  @connections =
52
52
  if options.key?(:puppetdb_url)
@@ -149,7 +149,7 @@ module OctocatalogDiff
149
149
 
150
150
  # Parse a URL to determine hostname, port number, and whether or not SSL is used.
151
151
  # @param url [String] URL to parse
152
- # @return [Hash] { ssl: true/false, host: <String>, port: <Fixnum> }
152
+ # @return [Hash] { ssl: true/false, host: <String>, port: <Integer> }
153
153
  def parse_url(url)
154
154
  uri = URI(url)
155
155
  raise ArgumentError, "URL #{url} has invalid scheme" unless uri.scheme =~ /^https?$/
@@ -99,6 +99,15 @@ module OctocatalogDiff
99
99
  # :nocov:
100
100
  end
101
101
 
102
+ # If catalogs failed to compile, report that. Prefer to display an actual failure message rather
103
+ # than a generic incomplete parallel task message if there is a more specific message present.
104
+ failures = parallel_catalogs.reject(&:status)
105
+ if failures.any?
106
+ f = failures.reject { |r| r.exception.is_a?(OctocatalogDiff::Util::Parallel::IncompleteTask) }.first
107
+ f ||= failures.first
108
+ raise f.exception
109
+ end
110
+
102
111
  # Construct result hash. Will eventually be in the format
103
112
  # { :from => OctocatalogDiff::Catalog, :to => OctocatalogDiff::Catalog }
104
113
 
@@ -203,10 +212,12 @@ module OctocatalogDiff
203
212
  end
204
213
  else
205
214
  # Something unhandled went wrong, and an exception was thrown. Reveal a generic message.
215
+ # :nocov:
206
216
  msg = parallel_catalog_obj.exception.message
207
217
  message = "Catalog for '#{key}' (#{branch}) failed to compile with #{parallel_catalog_obj.exception.class}: #{msg}"
208
218
  message += "\n" + parallel_catalog_obj.exception.backtrace.map { |x| " #{x}" }.join("\n") if @options[:debug]
209
219
  raise OctocatalogDiff::Errors::CatalogError, message
220
+ # :nocov:
210
221
  end
211
222
  end
212
223
 
@@ -220,22 +231,25 @@ module OctocatalogDiff
220
231
  time_start = Time.now
221
232
  catalog.build(logger)
222
233
  time_it_took = Time.now - time_start
223
- retries_str = " retries = #{catalog.retries}" if catalog.retries.is_a?(Fixnum)
234
+ retries_str = " retries = #{catalog.retries}" if catalog.retries.is_a?(Integer)
224
235
  time_str = "in #{time_it_took} seconds#{retries_str}"
225
236
  status_str = catalog.valid? ? 'successfully built' : 'failed'
226
237
  logger.debug "Catalog for #{opts[:branch]} #{status_str} with #{catalog.builder} #{time_str}"
227
238
  catalog
228
239
  end
229
240
 
230
- # Validate a catalog in the parallel execution
241
+ # The catalog validator method can indicate failure one of two ways:
242
+ # - Raise an exception (this is preferred, since it gives a specific error message)
243
+ # - Return false (supported but discouraged, since it only surfaces a generic error)
231
244
  # @param catalog [OctocatalogDiff::Catalog] Catalog object
232
245
  # @param logger [Logger] Logger object (presently unused)
233
246
  # @param args [Hash] Additional arguments set specifically for validator
234
- # @return [Boolean] true if catalog is valid, false otherwise
247
+ # @return [Boolean] Return true if catalog is valid, false otherwise
235
248
  def catalog_validator(catalog = nil, _logger = @logger, args = {})
236
- return false unless catalog.is_a?(OctocatalogDiff::Catalog)
237
- catalog.validate_references if args[:task] == :to
238
- catalog.valid?
249
+ raise ArgumentError, "Expects a catalog, got #{catalog.class}" unless catalog.is_a?(OctocatalogDiff::Catalog)
250
+ raise OctocatalogDiff::Errors::CatalogError, "Catalog failed: #{catalog.error_message}" unless catalog.valid?
251
+ catalog.validate_references if args[:task] == :to # Raises exception for broken references
252
+ true
239
253
  end
240
254
  end
241
255
  end
@@ -118,12 +118,20 @@ module OctocatalogDiff
118
118
  else
119
119
  raise ArgumentError, 'SSL client auth enabled but no client keypair specified'
120
120
  end
121
- if result[:pem]
122
- result[:pem_password] = options[:ssl_client_password] if options[:ssl_client_password]
123
- # Make sure there's not a password required, or that if the password is given, it is correct.
124
- # We do not want to wait on STDIN.
125
- # This will raise OpenSSL::PKey::RSAError if the key needs a password.
126
- OpenSSL::PKey::RSA.new(result[:pem], result[:pem_password] || '')
121
+
122
+ # Make sure there's not a password required, or that if the password is given, it is correct.
123
+ # This will raise OpenSSL::PKey::RSAError if the key needs a password.
124
+ if result[:pem] && options[:ssl_client_password]
125
+ result[:pem_password] = options[:ssl_client_password]
126
+ _trash = OpenSSL::PKey::RSA.new(result[:pem], result[:pem_password])
127
+ elsif result[:pem]
128
+ # Ruby 2.4 requires a minimum password length of 4. If no password is needed for
129
+ # the certificate, the specified password here is effectively ignored.
130
+ # We do not want to wait on STDIN, so a password-protected certificate without a
131
+ # password will cause this to raise an error. There are two checks here, to exclude
132
+ # an edge case where somebody did actually put '1234' as their password.
133
+ _trash = OpenSSL::PKey::RSA.new(result[:pem], '1234')
134
+ _trash = OpenSSL::PKey::RSA.new(result[:pem], '5678')
127
135
  end
128
136
  end
129
137
 
@@ -1,18 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Helper to use the 'parallel' gem to perform tasks
3
+ # A class to parallelize process executation.
4
+ # This is a utility class to execute tasks in parallel, with our own forking implementation
5
+ # that passes through logs and reliably handles errors. If parallel processing has been disabled,
6
+ # this instead executes the tasks serially, but provides the same API as the parallel tasks.
4
7
 
5
- require 'parallel'
6
8
  require 'stringio'
7
9
 
8
10
  module OctocatalogDiff
9
11
  module Util
10
- # This is a utility class to execute tasks in parallel, using the 'parallel' gem.
11
- # If parallel processing has been disabled, this instead executes the tasks serially,
12
- # but provides the same API as the parallel tasks.
13
12
  class Parallel
13
+ # This exception is called for a task that didn't complete.
14
+ class IncompleteTask < RuntimeError; end
15
+
16
+ # --------------------------------------
14
17
  # This class represents a parallel task. It requires a method reference, which will be executed with
15
18
  # any supplied arguments. It can optionally take a text description and a validator function.
19
+ # --------------------------------------
16
20
  class Task
17
21
  attr_reader :description
18
22
  attr_accessor :args
@@ -35,10 +39,12 @@ module OctocatalogDiff
35
39
  end
36
40
  end
37
41
 
42
+ # --------------------------------------
38
43
  # This class represents the result from a parallel task. The status is set to true (success), false (error),
39
44
  # or nil (task was killed before it could complete). The exception (for failure) and output object (for success)
40
45
  # are readable attributes. The validity of the results, determined by executing the 'validate' method of the Task,
41
46
  # is available to be set and fetched.
47
+ # --------------------------------------
42
48
  class Result
43
49
  attr_reader :output, :args
44
50
  attr_accessor :status, :exception
@@ -51,121 +57,170 @@ module OctocatalogDiff
51
57
  end
52
58
  end
53
59
 
60
+ # --------------------------------------
61
+ # Static methods in the class
62
+ # --------------------------------------
63
+
54
64
  # Entry point for parallel processing. By default this will perform parallel processing,
55
65
  # but it will also accept an option to do serial processing instead.
56
66
  # @param task_array [Array<Parallel::Task>] Tasks to run
57
67
  # @param logger [Logger] Optional logger object
58
68
  # @param parallelized [Boolean] True for parallel processing, false for serial processing
69
+ # @param raise_exception [Boolean] True to raise exception immediately if one occurs; false to return exception in results
59
70
  # @return [Array<Parallel::Result>] Parallel results (same order as tasks)
60
- def self.run_tasks(task_array, logger = nil, parallelized = true)
71
+ def self.run_tasks(task_array, logger = nil, parallelized = true, raise_exception = false)
61
72
  # Create a throwaway logger object if one is not given
62
73
  logger ||= Logger.new(StringIO.new)
63
74
 
64
- # Validate input - we need an array. If the array is empty then return an empty array right away.
75
+ # Validate input - we need an array of OctocatalogDiff::Util::Parallel::Task. If the array is empty then
76
+ # return an empty array right away.
65
77
  raise ArgumentError, "run_tasks() argument must be array, not #{task_array.class}" unless task_array.is_a?(Array)
66
78
  return [] if task_array.empty?
67
79
 
68
- # Make sure each element in the array is a OctocatalogDiff::Util::Parallel::Task
69
- task_array.each do |x|
70
- next if x.is_a?(OctocatalogDiff::Util::Parallel::Task)
71
- raise ArgumentError, "Element #{x.inspect} must be a OctocatalogDiff::Util::Parallel::Task, not a #{x.class}"
80
+ invalid_inputs = task_array.reject { |task| task.is_a?(OctocatalogDiff::Util::Parallel::Task) }
81
+ if invalid_inputs.any?
82
+ ele = invalid_inputs.first
83
+ raise ArgumentError, "Element #{ele.inspect} must be a OctocatalogDiff::Util::Parallel::Task, not a #{ele.class}"
72
84
  end
73
85
 
74
- # Actually do the processing - choose here between parallel and serial
75
- parallelized ? run_tasks_parallel(task_array, logger) : run_tasks_serial(task_array, logger)
86
+ # Initialize the result array. For now all entries in the array indicate that the task was killed.
87
+ # Actual statuses will replace this initial status. If the initial status wasn't replaced, then indeed,
88
+ # the task was killed.
89
+ result = task_array.map { |x| Result.new(exception: IncompleteTask.new('Killed'), args: x.args) }
90
+ logger.debug "Initialized parallel task result array: size=#{result.size}"
91
+
92
+ # Execute as per the requested method (serial or parallel) and handle results.
93
+ exception = parallelized ? run_tasks_parallel(result, task_array, logger) : run_tasks_serial(result, task_array, logger)
94
+ raise exception if exception && raise_exception
95
+ result
76
96
  end
77
97
 
78
- # Use the parallel gem to run each task in the task array. Under the hood this is forking a process for
79
- # each task, and serializing/deserializing the arguments and the outputs.
98
+ # Utility method! Not intended to be called from outside this class.
99
+ # ---
100
+ # Use a forking strategy to run tasks in parallel. Each task in the array is forked in a child
101
+ # process, and when that task completes it writes its result (OctocatalogDiff::Util::Parallel::Result)
102
+ # into a serialized data file. Once children are forked this method waits for their return, deserializing
103
+ # the output from each data file and updating the `result` array with actual results.
104
+ # @param result [Array<OctocatalogDiff::Util::Parallel::Result>] Parallel task results
80
105
  # @param task_array [Array<OctocatalogDiff::Util::Parallel::Task>] Tasks to perform
81
106
  # @param logger [Logger] Logger
82
- # @return [Array<OctocatalogDiff::Util::Parallel::Result>] Parallel task results
83
- def self.run_tasks_parallel(task_array, logger)
84
- # Create an empty array of results. The status is nil and the exception is pre-populated. If the code
85
- # runs successfully and doesn't get killed, all of these default values will be overwritten. If the code
86
- # gets killed before the task finishes, this exception will remain.
87
- result = task_array.map do |x|
88
- Result.new(exception: ::Parallel::Kill.new('Killed'), args: x.args)
107
+ # @return [Exception] First exception encountered by a child process; returns nil if no exceptions encountered.
108
+ def self.run_tasks_parallel(result, task_array, logger)
109
+ pidmap = {}
110
+ ipc_tempdir = Dir.mktmpdir
111
+
112
+ # Child process forking
113
+ task_array.each_with_index do |task, index|
114
+ # simplecov doesn't see this because it's forked
115
+ # :nocov:
116
+ this_pid = fork do
117
+ task_result = execute_task(task, logger)
118
+ File.open(File.join(ipc_tempdir, "#{Process.pid}.dat"), 'w') { |f| f.write Marshal.dump(task_result) }
119
+ Kernel.exit! 0 # Kernel.exit! avoids at_exit from parents being triggered by children exiting
120
+ end
121
+ # :nocov:
122
+
123
+ pidmap[this_pid] = { index: index, start_time: Time.now }
124
+ logger.debug "Launched pid=#{this_pid} for index=#{index}"
125
+ logger.reopen if logger.respond_to?(:reopen)
89
126
  end
90
- logger.debug "Initialized parallel task result array: size=#{result.size}"
91
127
 
92
- # Do parallel processing
93
- ::Parallel.each(task_array,
94
- finish: lambda do |item, i, parallel_result|
95
- # Set the result array element to the result
96
- result[i] = parallel_result
97
-
98
- # Kill all other parallel tasks if this task failed by throwing an exception
99
- raise ::Parallel::Kill unless parallel_result.exception.nil?
100
-
101
- # Run the validator to determine if the result is in fact valid. The validator
102
- # returns true or false. If true, set the 'valid' attribute in the result. If
103
- # false, kill all other parallel tasks.
104
- if item.validate(parallel_result.output, logger)
105
- logger.debug("Success #{item.description}")
106
- else
107
- logger.warn("Failed #{item.description}")
108
- result[i].status = false
109
- raise ::Parallel::Kill
110
- end
111
- end) do |ele|
112
- # simplecov does not detect that this code runs because it's forked, but this is
113
- # tested extensively in the parallel_spec.rb spec file.
114
- # :nocov:
128
+ # Waiting for children and handling results
129
+ while pidmap.any?
130
+ this_pid, exit_obj = Process.wait2(0)
131
+ next unless this_pid && pidmap.key?(this_pid)
132
+ index = pidmap[this_pid][:index]
133
+ exitstatus = exit_obj.exitstatus
134
+ raise "PID=#{this_pid} exited abnormally: #{exit_obj.inspect}" if exitstatus.nil?
135
+ raise "PID=#{this_pid} exited with status #{exitstatus}" unless exitstatus.zero?
136
+
137
+ input = File.read(File.join(ipc_tempdir, "#{this_pid}.dat"))
138
+ result[index] = Marshal.load(input) # rubocop:disable Security/MarshalLoad
139
+ time_delta = Time.now - pidmap[this_pid][:start_time]
140
+ pidmap.delete(this_pid)
141
+
142
+ logger.debug "PID=#{this_pid} completed in #{time_delta} seconds, #{input.length} bytes"
143
+
144
+ next if result[index].status
145
+ return result[index].exception
146
+ end
147
+
148
+ logger.debug 'All child processes completed with no exceptions raised'
149
+
150
+ # Cleanup: Kill any child processes that are still running, and clean the temporary directory
151
+ # where data files were stored.
152
+ ensure
153
+ pidmap.each do |pid, _pid_data|
115
154
  begin
116
- logger.debug("Begin #{ele.description}")
117
- output = ele.execute(logger)
118
- logger.debug("Success #{ele.description}")
119
- Result.new(output: output, status: true, args: ele.args)
120
- rescue => exc
121
- logger.debug("Failed #{ele.description}: #{exc.class} #{exc.message}")
122
- Result.new(exception: exc, status: false, args: ele.args)
155
+ Process.kill('TERM', pid)
156
+ rescue Errno::ESRCH # rubocop:disable Lint/HandleExceptions
157
+ # If the process doesn't exist, that's fine.
123
158
  end
124
- # :nocov:
125
159
  end
126
160
 
127
- # Return result
128
- result
161
+ retries = 0
162
+ while File.directory?(ipc_tempdir) && retries < 10
163
+ retries += 1
164
+ begin
165
+ FileUtils.remove_entry_secure ipc_tempdir
166
+ rescue Errno::ENOTEMPTY, Errno::ENOENT # rubocop:disable Lint/HandleExceptions
167
+ # Errno::ENOTEMPTY will trigger a retry because the directory exists
168
+ # Errno::ENOENT will break the loop because the directory won't exist next time it's checked
169
+ end
170
+ end
129
171
  end
130
172
 
173
+ # Utility method! Not intended to be called from outside this class.
174
+ # ---
131
175
  # Perform the tasks in serial.
176
+ # @param result [Array<OctocatalogDiff::Util::Parallel::Result>] Parallel task results
132
177
  # @param task_array [Array<OctocatalogDiff::Util::Parallel::Task>] Tasks to perform
133
178
  # @param logger [Logger] Logger
134
- # @return [Array<OctocatalogDiff::Util::Parallel::Result>] Parallel task results
135
- def self.run_tasks_serial(task_array, logger)
136
- # Create an empty array of results. The status is nil and the exception is pre-populated. If the code
137
- # runs successfully, all of these default values will be overwritten. If a predecessor task fails, all
138
- # later task will have the defined exception.
139
- result = task_array.map do |x|
140
- Result.new(exception: ::RuntimeError.new('Cancellation - A prior task failed'), args: x.args)
141
- end
142
-
179
+ def self.run_tasks_serial(result, task_array, logger)
143
180
  # Perform the tasks 1 by 1 - each successful task will replace an element in the 'result' array,
144
181
  # whereas a failed task will replace the current element with an exception, and all later tasks
145
182
  # will not be replaced (thereby being populated with the cancellation error).
146
- task_counter = 0
147
- task_array.each do |ele|
148
- begin
149
- logger.debug("Begin #{ele.description}")
150
- output = ele.execute(logger)
151
- result[task_counter] = Result.new(output: output, status: true, args: ele.args)
152
- rescue => exc
153
- logger.debug("Failed #{ele.description}: #{exc.class} #{exc.message}")
154
- result[task_counter] = Result.new(exception: exc, status: false, args: ele.args)
155
- end
183
+ task_array.each_with_index do |ele, task_counter|
184
+ result[task_counter] = execute_task(ele, logger)
185
+ next if result[task_counter].status
186
+ return result[task_counter].exception
187
+ end
188
+ nil
189
+ end
156
190
 
157
- if ele.validate(output, logger)
158
- logger.debug("Success #{ele.description}")
191
+ # Utility method! Not intended to be called from outside this class.
192
+ # ---
193
+ # Process a single task. Called by run_tasks_parallel / run_tasks_serial.
194
+ # This method will report all exceptions in the OctocatalogDiff::Util::Parallel::Result object
195
+ # itself, and not raise them.
196
+ # @param task [OctocatalogDiff::Util::Parallel::Task] Task object
197
+ # @param logger [Logger] Logger
198
+ # @return [OctocatalogDiff::Util::Parallel::Result] Parallel task result
199
+ def self.execute_task(task, logger)
200
+ begin
201
+ logger.debug("Begin #{task.description}")
202
+ output = task.execute(logger)
203
+ result = Result.new(output: output, status: true, args: task.args)
204
+ rescue => exc
205
+ logger.debug("Failed #{task.description}: #{exc.class} #{exc.message}")
206
+ # Immediately return without running the validation, since this already failed.
207
+ return Result.new(exception: exc, status: false, args: task.args)
208
+ end
209
+
210
+ begin
211
+ if task.validate(output, logger)
212
+ logger.debug("Success #{task.description}")
159
213
  else
160
- logger.warn("Failed #{ele.description}")
161
- result[task_counter].status = false
214
+ # Preferably the validator method raised its own exception. However if it
215
+ # simply returned false, raise our own exception here.
216
+ raise "Failed #{task.description} validation (unspecified error)"
162
217
  end
163
-
164
- break unless result[task_counter].status
165
- task_counter += 1
218
+ rescue => exc
219
+ logger.warn("Failed #{task.description} validation: #{exc.class} #{exc.message}")
220
+ result.status = false
221
+ result.exception = exc
166
222
  end
167
223
 
168
- # Return the result
169
224
  result
170
225
  end
171
226
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: octocatalog-diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitHub, Inc.
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2017-05-09 00:00:00.000000000 Z
12
+ date: 2017-05-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: diffy
@@ -53,20 +53,6 @@ dependencies:
53
53
  - - ">="
54
54
  - !ruby/object:Gem::Version
55
55
  version: 0.3.0
56
- - !ruby/object:Gem::Dependency
57
- name: parallel
58
- requirement: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: 1.11.1
63
- type: :runtime
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - ">="
68
- - !ruby/object:Gem::Version
69
- version: 1.11.1
70
56
  - !ruby/object:Gem::Dependency
71
57
  name: rugged
72
58
  requirement: !ruby/object:Gem::Requirement