octocatalog-diff 1.5.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +5 -5
  2. data/.version +1 -1
  3. data/README.md +4 -4
  4. data/doc/CHANGELOG.md +50 -0
  5. data/doc/advanced-compare-file-text.md +79 -0
  6. data/doc/advanced-environments.md +5 -5
  7. data/doc/advanced-ignores.md +10 -0
  8. data/doc/advanced-puppet-master.md +25 -7
  9. data/doc/configuration-puppet.md +19 -19
  10. data/doc/configuration-puppetdb.md +8 -0
  11. data/doc/configuration.md +31 -31
  12. data/doc/dev/api/v1/calls/catalog-diff.md +6 -2
  13. data/doc/dev/api/v1/objects/diff.md +3 -3
  14. data/doc/dev/integration-tests.md +2 -2
  15. data/doc/dev/releasing.md +41 -41
  16. data/doc/dev/run-from-branch.md +23 -23
  17. data/doc/installation.md +14 -14
  18. data/doc/limitations.md +9 -9
  19. data/doc/optionsref.md +166 -11
  20. data/doc/requirements.md +6 -2
  21. data/lib/octocatalog-diff/catalog-diff/differ.rb +32 -5
  22. data/lib/octocatalog-diff/catalog-diff/filter/compilation_dir.rb +29 -25
  23. data/lib/octocatalog-diff/catalog-util/command.rb +25 -3
  24. data/lib/octocatalog-diff/catalog-util/fileresources.rb +39 -16
  25. data/lib/octocatalog-diff/catalog.rb +22 -4
  26. data/lib/octocatalog-diff/catalog/computed.rb +2 -1
  27. data/lib/octocatalog-diff/catalog/puppetmaster.rb +43 -5
  28. data/lib/octocatalog-diff/cli.rb +38 -6
  29. data/lib/octocatalog-diff/cli/options.rb +36 -1
  30. data/lib/octocatalog-diff/cli/options/compare_file_text.rb +18 -0
  31. data/lib/octocatalog-diff/cli/options/hostname.rb +13 -2
  32. data/lib/octocatalog-diff/cli/options/puppet_master_api_version.rb +2 -2
  33. data/lib/octocatalog-diff/cli/options/puppet_master_token.rb +20 -0
  34. data/lib/octocatalog-diff/cli/options/puppet_master_token_file.rb +35 -0
  35. data/lib/octocatalog-diff/cli/options/puppet_master_update_catalog.rb +20 -0
  36. data/lib/octocatalog-diff/cli/options/puppet_master_update_facts.rb +20 -0
  37. data/lib/octocatalog-diff/cli/options/puppetdb_package_inventory.rb +18 -0
  38. data/lib/octocatalog-diff/cli/options/use_lcs.rb +14 -0
  39. data/lib/octocatalog-diff/facts/json.rb +13 -2
  40. data/lib/octocatalog-diff/facts/puppetdb.rb +43 -2
  41. data/lib/octocatalog-diff/util/parallel.rb +20 -16
  42. data/lib/octocatalog-diff/util/util.rb +2 -0
  43. data/scripts/env/env.sh +1 -1
  44. data/scripts/git-extract/git-extract.sh +1 -1
  45. data/scripts/puppet/puppet.sh +1 -1
  46. metadata +36 -30
@@ -11,6 +11,7 @@ require_relative 'util/util'
11
11
  require_relative 'version'
12
12
 
13
13
  require 'logger'
14
+ require 'parallel'
14
15
  require 'socket'
15
16
 
16
17
  module OctocatalogDiff
@@ -43,7 +44,8 @@ module OctocatalogDiff
43
44
  display_datatype_changes: true,
44
45
  parallel: true,
45
46
  suppress_absent_file_details: true,
46
- hiera_path: 'hieradata'
47
+ hiera_path: 'hieradata',
48
+ use_lcs: true
47
49
  }.freeze
48
50
 
49
51
  # This method is the one to call externally. It is possible to specify alternate
@@ -116,16 +118,46 @@ module OctocatalogDiff
116
118
  end
117
119
 
118
120
  # Compile catalogs and do catalog-diff
119
- catalog_diff = OctocatalogDiff::API::V1.catalog_diff(options.merge(logger: logger))
121
+ node_set = options.delete(:node)
122
+ node_set = [node_set] unless node_set.is_a?(Array)
123
+
124
+ # run multiple node diffs in parallel
125
+ catalog_diffs = if node_set.size == 1
126
+ [run_octocatalog_diff(node_set.first, options, logger)]
127
+ else
128
+ ::Parallel.map(node_set, in_threads: 4) { |node| run_octocatalog_diff(node, options, logger) }
129
+ end
130
+
131
+ # Return the resulting diff object if requested (generally for testing)
132
+ # or otherwise return exit code
133
+ return catalog_diffs.first if opts[:INTEGRATION]
134
+
135
+ all_diffs = catalog_diffs.map(&:diffs)
136
+
137
+ all_diffs.each do |diff|
138
+ next unless diff.any?
139
+ return EXITCODE_SUCCESS_WITH_DIFFS
140
+ end
141
+
142
+ EXITCODE_SUCCESS_NO_DIFFS
143
+ end
144
+
145
+ # Run the octocatalog-diff process for a given node. Return the diffs for a contribution to
146
+ # the final exit status.
147
+ # node - String with the node
148
+ # options - All of the currently defined options
149
+ # logger - Logger object
150
+ def self.run_octocatalog_diff(node, options, logger)
151
+ options_copy = options.merge(node: node)
152
+ catalog_diff = OctocatalogDiff::API::V1.catalog_diff(options_copy.merge(logger: logger))
120
153
  diffs = catalog_diff.diffs
121
154
 
122
155
  # Display diffs
123
- printer_obj = OctocatalogDiff::Cli::Printer.new(options, logger)
156
+ printer_obj = OctocatalogDiff::Cli::Printer.new(options_copy, logger)
124
157
  printer_obj.printer(diffs, catalog_diff.from.compilation_dir, catalog_diff.to.compilation_dir)
125
158
 
126
- # Return the resulting diff object if requested (generally for testing) or otherwise return exit code
127
- return catalog_diff if opts[:INTEGRATION]
128
- diffs.any? ? EXITCODE_SUCCESS_WITH_DIFFS : EXITCODE_SUCCESS_NO_DIFFS
159
+ # Return catalog-diff object.
160
+ catalog_diff
129
161
  end
130
162
 
131
163
  # Parse command line options with 'optparse'. Returns a hash with the parsed arguments.
@@ -11,7 +11,7 @@ module OctocatalogDiff
11
11
  # This class contains the option parser. 'parse_options' is the external entry point.
12
12
  class Options
13
13
  # The usage banner.
14
- BANNER = 'Usage: catalog-diff -n <hostname> [-f <from environment>] [-t <to environment>]'.freeze
14
+ BANNER = 'Usage: catalog-diff -n <hostname>[,<hostname>...] [-f <from environment>] [-t <to environment>]'.freeze
15
15
 
16
16
  # An error class specifically for passing information to the document build task.
17
17
  class DocBuildError < RuntimeError; end
@@ -103,6 +103,7 @@ module OctocatalogDiff
103
103
  datatype = opts.fetch(:datatype, '')
104
104
  return option_globally_or_per_branch_string(opts) if datatype.is_a?(String)
105
105
  return option_globally_or_per_branch_array(opts) if datatype.is_a?(Array)
106
+ return option_globally_or_per_branch_boolean(opts) if datatype.is_a?(TrueClass) || datatype.is_a?(FalseClass)
106
107
  raise ArgumentError, "option_globally_or_per_branch not equipped to handle #{datatype.class}"
107
108
  end
108
109
 
@@ -177,6 +178,40 @@ module OctocatalogDiff
177
178
  end
178
179
  end
179
180
 
181
+ # See description of `option_globally_or_per_branch`. This implements the logic for a boolean value.
182
+ # @param :parser [OptionParser object] The OptionParser argument
183
+ # @param :options [Hash] Options hash being constructed; this is modified in this method.
184
+ # @param :cli_name [String] Name of option on command line (e.g. puppet-binary)
185
+ # @param :option_name [Symbol] Name of option in the options hash (e.g. :puppet_binary)
186
+ # @param :desc [String] Description of option on the command line; will have "for the XX branch" appended
187
+ def self.option_globally_or_per_branch_boolean(opts)
188
+ parser = opts.fetch(:parser)
189
+ options = opts.fetch(:options)
190
+ cli_name = opts.fetch(:cli_name)
191
+ option_name = opts.fetch(:option_name)
192
+ desc = opts.fetch(:desc)
193
+
194
+ flag = cli_name
195
+ from_option = "from_#{option_name}".to_sym
196
+ to_option = "to_#{option_name}".to_sym
197
+ parser.on("--[no-]#{flag}", "#{desc} globally") do |x|
198
+ translated = translate_option(opts[:translator], x)
199
+ options[to_option] = translated
200
+ options[from_option] = translated
201
+ post_process(opts[:post_process], options)
202
+ end
203
+ parser.on("--[no-]to-#{flag}", "#{desc} for the to branch") do |x|
204
+ translated = translate_option(opts[:translator], x)
205
+ options[to_option] = translated
206
+ post_process(opts[:post_process], options)
207
+ end
208
+ parser.on("--[no-]from-#{flag}", "#{desc} for the from branch") do |x|
209
+ translated = translate_option(opts[:translator], x)
210
+ options[from_option] = translated
211
+ post_process(opts[:post_process], options)
212
+ end
213
+ end
214
+
180
215
  # If a validator was provided, run the validator on the supplied value. The validator is expected to
181
216
  # throw an error if there is a problem. Note that the validator runs *before* the translator if both
182
217
  # a validator and translator are supplied.
@@ -15,3 +15,21 @@ OctocatalogDiff::Cli::Options::Option.newoption(:compare_file_text) do
15
15
  end
16
16
  end
17
17
  end
18
+
19
+ # Sometimes there is a particular file resource for which the file text
20
+ # comparison is not desired, while not disabling that option globally. Similar
21
+ # to --ignore_tags, it's possible to tag the file resource and exempt it from
22
+ # the --compare_file_text checks.
23
+ # @param parser [OptionParser object] The OptionParser argument
24
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
25
+ OctocatalogDiff::Cli::Options::Option.newoption(:compare_file_text_ignore_tags) do
26
+ has_weight 415
27
+
28
+ def parse(parser, options)
29
+ description = 'Tags that exclude a file resource from text comparison'
30
+ parser.on('--compare-file-text-ignore-tags STRING1[,STRING2[,...]]', Array, description) do |x|
31
+ options[:compare_file_text_ignore_tags] ||= []
32
+ options[:compare_file_text_ignore_tags].concat x
33
+ end
34
+ end
35
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Set hostname, which is used to look up facts in PuppetDB, and in the header of diff display.
4
+ # This option can recieve a single hostname, or a comma separated list of
5
+ # multiple hostnames, which are split into an Array. Multiple hostnames do not
6
+ # work with the `catalog-only` or `bootstrap-then-exit` options.
4
7
  # @param parser [OptionParser object] The OptionParser argument
5
8
  # @param options [Hash] Options hash being constructed; this is modified in this method.
6
9
 
@@ -8,8 +11,16 @@ OctocatalogDiff::Cli::Options::Option.newoption(:hostname) do
8
11
  has_weight 1
9
12
 
10
13
  def parse(parser, options)
11
- parser.on('--hostname HOSTNAME', '-n', 'Use PuppetDB facts from last run of hostname') do |hostname|
12
- options[:node] = hostname
14
+ parser.on(
15
+ '--hostname HOSTNAME1[,HOSTNAME2[,...]]',
16
+ '-n',
17
+ 'Use PuppetDB facts from last run of a hostname or a comma separated list of multiple hostnames'
18
+ ) do |hostname|
19
+ options[:node] = if hostname.include?(',')
20
+ hostname.split(',')
21
+ else
22
+ hostname
23
+ end
13
24
  end
14
25
  end
15
26
  end
@@ -14,8 +14,8 @@ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_api_version) do
14
14
  options: options,
15
15
  cli_name: 'puppet-master-api-version',
16
16
  option_name: 'puppet_master_api_version',
17
- desc: 'Puppet Master API version (2 for Puppet 3.x, 3 for Puppet 4.x)',
18
- validator: ->(x) { x =~ /^[23]$/ || raise(ArgumentError, 'Only API versions 2 and 3 are supported') },
17
+ desc: 'Puppet Master API version (2 for Puppet 3.x, 3 for Puppet 4.x, 4 for Puppet Server >= 6.3.0)',
18
+ validator: ->(x) { x =~ /^[234]$/ || raise(ArgumentError, 'Only API versions 2, 3, and 4 are supported') },
19
19
  translator: ->(x) { x.to_i }
20
20
  )
21
21
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Specify a PE RBAC token used to authenticate to Puppetserver for v4
4
+ # catalog API calls.
5
+ # @param parser [OptionParser object] The OptionParser argument
6
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
7
+ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_token) do
8
+ has_weight 310
9
+
10
+ def parse(parser, options)
11
+ OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12
+ parser: parser,
13
+ options: options,
14
+ datatype: '',
15
+ cli_name: 'puppet-master-token',
16
+ option_name: 'puppet_master_token',
17
+ desc: 'PE RBAC token to authenticate to the Puppetserver API v4'
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Specify a path to a file containing a PE RBAC token used to authenticate to the
4
+ # Puppetserver for a v4 catalog API call.
5
+ # @param parser [OptionParser object] The OptionParser argument
6
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
7
+ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_token_file) do
8
+ has_weight 300
9
+
10
+ def parse(parser, options)
11
+ OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12
+ parser: parser,
13
+ options: options,
14
+ datatype: '',
15
+ cli_name: 'puppet-master-token-file',
16
+ option_name: 'puppet_master_token_file',
17
+ desc: 'File containing PE RBAC token to authenticate to the Puppetserver API v4',
18
+ translator: ->(x) { x.start_with?('/', '~') ? x : File.join(options[:basedir], x) },
19
+ post_process: lambda do |opts|
20
+ %w(to from).each do |prefix|
21
+ fileopt = "#{prefix}_puppet_master_token_file".to_sym
22
+ tokenopt = "#{prefix}_puppet_master_token".to_sym
23
+
24
+ tokenfile = opts[fileopt]
25
+ next if tokenfile.nil?
26
+
27
+ raise(Errno::ENOENT, "Token file #{tokenfile} is not readable") unless File.readable?(tokenfile)
28
+
29
+ token = File.read(tokenfile).strip
30
+ opts[tokenopt] ||= token
31
+ end
32
+ end
33
+ )
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Specify if, when using the Puppetserver v4 catalog API, the Puppetserver should
4
+ # update the catalog in PuppetDB.
5
+ # @param parser [OptionParser object] The OptionParser argument
6
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
7
+ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_update_catalog) do
8
+ has_weight 320
9
+
10
+ def parse(parser, options)
11
+ OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12
+ parser: parser,
13
+ options: options,
14
+ datatype: false,
15
+ cli_name: 'puppet-master-update-catalog',
16
+ option_name: 'puppet_master_update_catalog',
17
+ desc: 'Update catalog in PuppetDB when using Puppetmaster API version 4'
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Specify if, when using the Puppetserver v4 catalog API, the Puppetserver should
4
+ # update the facts in PuppetDB.
5
+ # @param parser [OptionParser object] The OptionParser argument
6
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
7
+ OctocatalogDiff::Cli::Options::Option.newoption(:puppet_master_update_facts) do
8
+ has_weight 320
9
+
10
+ def parse(parser, options)
11
+ OctocatalogDiff::Cli::Options.option_globally_or_per_branch(
12
+ parser: parser,
13
+ options: options,
14
+ datatype: false,
15
+ cli_name: 'puppet-master-update-facts',
16
+ option_name: 'puppet_master_update_facts',
17
+ desc: 'Update facts in PuppetDB when using Puppetmaster API version 4'
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # When pulling facts from PuppetDB in a Puppet Enterprise environment, also include
4
+ # the Puppet Enterprise Package Inventory data in the fact results, if available.
5
+ # Generally you should not need to specify this, but including the package inventory
6
+ # data will produce a more accurate set of input facts for environments using
7
+ # package inventory.
8
+ # @param parser [OptionParser object] The OptionParser argument
9
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
10
+ OctocatalogDiff::Cli::Options::Option.newoption(:puppetdb_package_inventory) do
11
+ has_weight 150
12
+
13
+ def parse(parser, options)
14
+ parser.on('--[no-]puppetdb-package-inventory', 'Include Puppet Enterprise package inventory data, if found') do |x|
15
+ options[:puppetdb_package_inventory] = x
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configures using the Longest common subsequence (LCS) algorithm to determine differences in arrays
4
+ # @param parser [OptionParser object] The OptionParser argument
5
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
6
+ OctocatalogDiff::Cli::Options::Option.newoption(:use_lcs) do
7
+ has_weight 250
8
+
9
+ def parse(parser, options)
10
+ parser.on('--[no-]use-lcs', 'Use the LCS algorithm to determine differences in arrays') do |x|
11
+ options[:use_lcs] = x
12
+ end
13
+ end
14
+ end
@@ -14,8 +14,19 @@ module OctocatalogDiff
14
14
  # @return [Hash] Facts
15
15
  def self.fact_retriever(options = {}, node = '')
16
16
  facts = ::JSON.parse(options.fetch(:fact_file_string))
17
- node = facts.fetch('fqdn', 'unknown.node') if node.empty?
18
- { 'name' => node, 'values' => facts }
17
+
18
+ if facts.keys.include?('name') && facts.keys.include?('values') && facts['values'].is_a?(Hash)
19
+ # If you saved the output of something like
20
+ # `puppet facts find $(hostname)` the structure will already be a
21
+ # {'name' => <fqdn>, 'values' => <hash of facts>}. We do nothing
22
+ # here because we don't want to double-encode.
23
+ else
24
+ facts = { 'name' => node, 'values' => facts }
25
+ end
26
+
27
+ facts['name'] = node unless node.empty?
28
+ facts['name'] = facts['values'].fetch('fqdn', 'unknown.node') if facts['name'].empty?
29
+ facts
19
30
  end
20
31
  end
21
32
  end
@@ -36,6 +36,7 @@ module OctocatalogDiff
36
36
  exception_class = nil
37
37
  exception_message = nil
38
38
  obj_to_return = nil
39
+ packages = nil
39
40
  (retries + 1).times do
40
41
  begin
41
42
  result = puppetdb.get(uri)
@@ -61,8 +62,48 @@ module OctocatalogDiff
61
62
  exception_message = "Fact retrieval failed for node #{node} from PuppetDB (#{exc.message})"
62
63
  end
63
64
  end
64
- return obj_to_return unless obj_to_return.nil?
65
- raise exception_class, exception_message
65
+
66
+ raise exception_class, exception_message if obj_to_return.nil?
67
+
68
+ return obj_to_return if puppetdb_api_version < 4 || (!options[:puppetdb_package_inventory])
69
+
70
+ (retries + 1).times do
71
+ begin
72
+ result = puppetdb.get("/pdb/query/v4/package-inventory/#{node}")
73
+ packages = {}
74
+ result.each do |pkg|
75
+ key = "#{pkg['package_name']}+#{pkg['provider']}"
76
+ # Need to handle the situation where a package has multiple versions installed.
77
+ # The _puppet_inventory_1 hash lists them separated by "; ".
78
+ if packages.key?(key)
79
+ packages[key]['version'] += "; #{pkg['version']}"
80
+ else
81
+ packages[key] = pkg
82
+ end
83
+ end
84
+ break
85
+ rescue OctocatalogDiff::Errors::PuppetDBConnectionError => exc
86
+ exception_class = OctocatalogDiff::Errors::FactSourceError
87
+ exception_message = "Package inventory retrieval failed (#{exc.class}) (#{exc.message})"
88
+ # This is not expected to occur, but we'll leave it just in case. A query to package-inventory
89
+ # for a non-existant node returns a 200 OK with an empty list of packages:
90
+ rescue OctocatalogDiff::Errors::PuppetDBNodeNotFoundError
91
+ packages = {}
92
+ rescue OctocatalogDiff::Errors::PuppetDBGenericError => exc
93
+ exception_class = OctocatalogDiff::Errors::FactRetrievalError
94
+ exception_message = "Package inventory retrieval failed for node #{node} from PuppetDB (#{exc.message})"
95
+ end
96
+ end
97
+
98
+ raise exception_class, exception_message if packages.nil?
99
+
100
+ unless packages.empty?
101
+ obj_to_return['values']['_puppet_inventory_1'] = {
102
+ 'packages' => packages.values.map { |pkg| [pkg['package_name'], pkg['version'], pkg['provider']] }
103
+ }
104
+ end
105
+
106
+ obj_to_return
66
107
  end
67
108
  end
68
109
  end
@@ -129,22 +129,26 @@ module OctocatalogDiff
129
129
 
130
130
  # Waiting for children and handling results
131
131
  while pidmap.any?
132
- this_pid, exit_obj = Process.wait2(0)
133
- next unless this_pid && pidmap.key?(this_pid)
134
- index = pidmap[this_pid][:index]
135
- exitstatus = exit_obj.exitstatus
136
- raise "PID=#{this_pid} exited abnormally: #{exit_obj.inspect}" if exitstatus.nil?
137
- raise "PID=#{this_pid} exited with status #{exitstatus}" unless exitstatus.zero?
138
-
139
- input = File.read(File.join(ipc_tempdir, "#{this_pid}.dat"))
140
- result[index] = Marshal.load(input) # rubocop:disable Security/MarshalLoad
141
- time_delta = Time.now - pidmap[this_pid][:start_time]
142
- pidmap.delete(this_pid)
143
-
144
- logger.debug "PID=#{this_pid} completed in #{time_delta} seconds, #{input.length} bytes"
145
-
146
- next if result[index].status
147
- return result[index].exception
132
+ pidmap.each do |pid|
133
+ status = Process.waitpid2(pid[0], Process::WNOHANG)
134
+ next if status.nil?
135
+ this_pid, exit_obj = status
136
+ next unless this_pid && pidmap.key?(this_pid)
137
+ index = pidmap[this_pid][:index]
138
+ exitstatus = exit_obj.exitstatus
139
+ raise "PID=#{this_pid} exited abnormally: #{exit_obj.inspect}" if exitstatus.nil?
140
+ raise "PID=#{this_pid} exited with status #{exitstatus}" unless exitstatus.zero?
141
+
142
+ input = File.read(File.join(ipc_tempdir, "#{this_pid}.dat"))
143
+ result[index] = Marshal.load(input) # rubocop:disable Security/MarshalLoad
144
+ time_delta = Time.now - pidmap[this_pid][:start_time]
145
+ pidmap.delete(this_pid)
146
+
147
+ logger.debug "PID=#{this_pid} completed in #{time_delta} seconds, #{input.length} bytes"
148
+
149
+ next if result[index].status
150
+ return result[index].exception
151
+ end
148
152
  end
149
153
 
150
154
  logger.debug 'All child processes completed with no exceptions raised'