octocatalog-diff 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/.version +1 -0
  3. data/LICENSE +20 -0
  4. data/README.md +82 -0
  5. data/bin/octocatalog-diff +75 -0
  6. data/doc/advanced-bootstrap.md +33 -0
  7. data/doc/advanced-cache-dir.md +24 -0
  8. data/doc/advanced-catalog-only.md +37 -0
  9. data/doc/advanced-ci.md +13 -0
  10. data/doc/advanced-dynamic-ignores.md +123 -0
  11. data/doc/advanced-future-parser.md +11 -0
  12. data/doc/advanced-ignores.md +224 -0
  13. data/doc/advanced-output-formats.md +96 -0
  14. data/doc/advanced-output-hacks.md +45 -0
  15. data/doc/advanced-override-facts.md +67 -0
  16. data/doc/advanced-pe-enc.md +52 -0
  17. data/doc/advanced-puppet-master.md +50 -0
  18. data/doc/advanced-puppet-versions.md +9 -0
  19. data/doc/advanced-storeconfigs.md +72 -0
  20. data/doc/advanced-using-without-git.md +15 -0
  21. data/doc/advanced.md +43 -0
  22. data/doc/basic.md +70 -0
  23. data/doc/configuration-enc.md +69 -0
  24. data/doc/configuration-hiera.md +103 -0
  25. data/doc/configuration-puppetdb.md +49 -0
  26. data/doc/configuration.md +51 -0
  27. data/doc/dev/README.md +1 -0
  28. data/doc/dev/coverage.md +34 -0
  29. data/doc/dev/how-to-add-options.md +83 -0
  30. data/doc/dev/integration-tests.md +63 -0
  31. data/doc/dev/releasing.md +19 -0
  32. data/doc/installation.md +49 -0
  33. data/doc/limitations.md +34 -0
  34. data/doc/optionsref.md +947 -0
  35. data/doc/requirements.md +16 -0
  36. data/doc/roadmap.md +26 -0
  37. data/doc/similar.md +17 -0
  38. data/doc/troubleshooting.md +54 -0
  39. data/lib/octocatalog-diff.rb +12 -0
  40. data/lib/octocatalog-diff/bootstrap.rb +53 -0
  41. data/lib/octocatalog-diff/catalog-diff/cli.rb +205 -0
  42. data/lib/octocatalog-diff/catalog-diff/cli/catalogs.rb +240 -0
  43. data/lib/octocatalog-diff/catalog-diff/cli/diffs.rb +145 -0
  44. data/lib/octocatalog-diff/catalog-diff/cli/helpers/fact_override.rb +99 -0
  45. data/lib/octocatalog-diff/catalog-diff/cli/options.rb +173 -0
  46. data/lib/octocatalog-diff/catalog-diff/cli/options/basedir.rb +14 -0
  47. data/lib/octocatalog-diff/catalog-diff/cli/options/bootstrap_environment.rb +18 -0
  48. data/lib/octocatalog-diff/catalog-diff/cli/options/bootstrap_script.rb +14 -0
  49. data/lib/octocatalog-diff/catalog-diff/cli/options/bootstrap_then_exit.rb +12 -0
  50. data/lib/octocatalog-diff/catalog-diff/cli/options/bootstrapped_dirs.rb +18 -0
  51. data/lib/octocatalog-diff/catalog-diff/cli/options/cached_master_dir.rb +21 -0
  52. data/lib/octocatalog-diff/catalog-diff/cli/options/catalog_only.rb +14 -0
  53. data/lib/octocatalog-diff/catalog-diff/cli/options/color.rb +13 -0
  54. data/lib/octocatalog-diff/catalog-diff/cli/options/compare_file_text.rb +15 -0
  55. data/lib/octocatalog-diff/catalog-diff/cli/options/debug.rb +12 -0
  56. data/lib/octocatalog-diff/catalog-diff/cli/options/display_datatype_changes.rb +16 -0
  57. data/lib/octocatalog-diff/catalog-diff/cli/options/display_detail_add.rb +12 -0
  58. data/lib/octocatalog-diff/catalog-diff/cli/options/display_source_file_line.rb +12 -0
  59. data/lib/octocatalog-diff/catalog-diff/cli/options/enc.rb +31 -0
  60. data/lib/octocatalog-diff/catalog-diff/cli/options/existing_catalogs.rb +25 -0
  61. data/lib/octocatalog-diff/catalog-diff/cli/options/fact_file.rb +23 -0
  62. data/lib/octocatalog-diff/catalog-diff/cli/options/fact_override.rb +19 -0
  63. data/lib/octocatalog-diff/catalog-diff/cli/options/facts_terminus.rb +16 -0
  64. data/lib/octocatalog-diff/catalog-diff/cli/options/from_puppetdb.rb +13 -0
  65. data/lib/octocatalog-diff/catalog-diff/cli/options/header.rb +24 -0
  66. data/lib/octocatalog-diff/catalog-diff/cli/options/hiera_config.rb +18 -0
  67. data/lib/octocatalog-diff/catalog-diff/cli/options/hiera_path_strip.rb +12 -0
  68. data/lib/octocatalog-diff/catalog-diff/cli/options/hostname.rb +13 -0
  69. data/lib/octocatalog-diff/catalog-diff/cli/options/ignore.rb +24 -0
  70. data/lib/octocatalog-diff/catalog-diff/cli/options/ignore_attr.rb +16 -0
  71. data/lib/octocatalog-diff/catalog-diff/cli/options/ignore_tags.rb +23 -0
  72. data/lib/octocatalog-diff/catalog-diff/cli/options/include_tags.rb +12 -0
  73. data/lib/octocatalog-diff/catalog-diff/cli/options/master_cache_branch.rb +12 -0
  74. data/lib/octocatalog-diff/catalog-diff/cli/options/output_file.rb +15 -0
  75. data/lib/octocatalog-diff/catalog-diff/cli/options/output_format.rb +15 -0
  76. data/lib/octocatalog-diff/catalog-diff/cli/options/parallel.rb +12 -0
  77. data/lib/octocatalog-diff/catalog-diff/cli/options/parser.rb +48 -0
  78. data/lib/octocatalog-diff/catalog-diff/cli/options/pass_env_vars.rb +19 -0
  79. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_ssl_ca.rb +15 -0
  80. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_ssl_client_cert.rb +14 -0
  81. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_ssl_client_key.rb +14 -0
  82. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_token.rb +15 -0
  83. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_token_file.rb +17 -0
  84. data/lib/octocatalog-diff/catalog-diff/cli/options/pe_enc_url.rb +19 -0
  85. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_binary.rb +16 -0
  86. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_master.rb +16 -0
  87. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_master_api_version.rb +20 -0
  88. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_master_ssl_ca.rb +19 -0
  89. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_master_ssl_client_cert.rb +19 -0
  90. data/lib/octocatalog-diff/catalog-diff/cli/options/puppet_master_ssl_client_key.rb +19 -0
  91. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_ssl_ca.rb +15 -0
  92. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_ssl_client_cert.rb +14 -0
  93. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_ssl_client_key.rb +14 -0
  94. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_ssl_client_password.rb +14 -0
  95. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_ssl_client_password_file.rb +13 -0
  96. data/lib/octocatalog-diff/catalog-diff/cli/options/puppetdb_url.rb +18 -0
  97. data/lib/octocatalog-diff/catalog-diff/cli/options/quiet.rb +12 -0
  98. data/lib/octocatalog-diff/catalog-diff/cli/options/retry_failed_catalog.rb +13 -0
  99. data/lib/octocatalog-diff/catalog-diff/cli/options/safe_to_delete_cached_master_dir.rb +15 -0
  100. data/lib/octocatalog-diff/catalog-diff/cli/options/storeconfigs.rb +12 -0
  101. data/lib/octocatalog-diff/catalog-diff/cli/options/suppress_absent_file_details.rb +14 -0
  102. data/lib/octocatalog-diff/catalog-diff/cli/options/to_from_branch.rb +16 -0
  103. data/lib/octocatalog-diff/catalog-diff/cli/printer.rb +52 -0
  104. data/lib/octocatalog-diff/catalog-diff/differ.rb +615 -0
  105. data/lib/octocatalog-diff/catalog-diff/display.rb +125 -0
  106. data/lib/octocatalog-diff/catalog-diff/display/json.rb +25 -0
  107. data/lib/octocatalog-diff/catalog-diff/display/text.rb +452 -0
  108. data/lib/octocatalog-diff/catalog-util/bootstrap.rb +145 -0
  109. data/lib/octocatalog-diff/catalog-util/builddir.rb +289 -0
  110. data/lib/octocatalog-diff/catalog-util/cached_master_directory.rb +169 -0
  111. data/lib/octocatalog-diff/catalog-util/command.rb +96 -0
  112. data/lib/octocatalog-diff/catalog-util/enc.rb +77 -0
  113. data/lib/octocatalog-diff/catalog-util/enc/noop.rb +22 -0
  114. data/lib/octocatalog-diff/catalog-util/enc/pe.rb +99 -0
  115. data/lib/octocatalog-diff/catalog-util/enc/pe/v1.rb +61 -0
  116. data/lib/octocatalog-diff/catalog-util/enc/script.rb +88 -0
  117. data/lib/octocatalog-diff/catalog-util/facts.rb +89 -0
  118. data/lib/octocatalog-diff/catalog-util/fileresources.rb +83 -0
  119. data/lib/octocatalog-diff/catalog-util/git.rb +65 -0
  120. data/lib/octocatalog-diff/catalog.rb +209 -0
  121. data/lib/octocatalog-diff/catalog/computed.rb +205 -0
  122. data/lib/octocatalog-diff/catalog/json.rb +30 -0
  123. data/lib/octocatalog-diff/catalog/noop.rb +19 -0
  124. data/lib/octocatalog-diff/catalog/puppetdb.rb +82 -0
  125. data/lib/octocatalog-diff/catalog/puppetmaster.rb +121 -0
  126. data/lib/octocatalog-diff/external/pson/LICENSE +17 -0
  127. data/lib/octocatalog-diff/external/pson/README.md +20 -0
  128. data/lib/octocatalog-diff/external/pson/common.rb +370 -0
  129. data/lib/octocatalog-diff/external/pson/pure.rb +15 -0
  130. data/lib/octocatalog-diff/external/pson/pure/generator.rb +395 -0
  131. data/lib/octocatalog-diff/external/pson/pure/parser.rb +307 -0
  132. data/lib/octocatalog-diff/external/pson/version.rb +8 -0
  133. data/lib/octocatalog-diff/facts.rb +125 -0
  134. data/lib/octocatalog-diff/facts/json.rb +20 -0
  135. data/lib/octocatalog-diff/facts/puppetdb.rb +59 -0
  136. data/lib/octocatalog-diff/facts/yaml.rb +29 -0
  137. data/lib/octocatalog-diff/puppetdb.rb +163 -0
  138. data/lib/octocatalog-diff/util/colored.rb +20 -0
  139. data/lib/octocatalog-diff/util/httparty.rb +158 -0
  140. data/lib/octocatalog-diff/util/parallel.rb +170 -0
  141. data/lib/octocatalog-diff/util/puppetversion.rb +24 -0
  142. data/lib/octocatalog-diff/version.rb +7 -0
  143. metadata +386 -0
@@ -0,0 +1,18 @@
1
+ require 'uri'
2
+
3
+ # Specify the base URL for PuppetDB. This will generally look like https://puppetdb.yourdomain.com:8081
4
+ # @param parser [OptionParser object] The OptionParser argument
5
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
6
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:puppetdb_url) do
7
+ has_weight 310
8
+
9
+ def parse(parser, options)
10
+ parser.on('--puppetdb-url URL', 'PuppetDB base URL') do |url|
11
+ # Test the format of the incoming URL. Only HTTPS should really be used, but we will
12
+ # support HTTP begrudgingly as well.
13
+ obj = URI.parse(url)
14
+ raise ArgumentError, 'PuppetDB URL must be http or https' unless obj.is_a?(URI::HTTPS) || obj.is_a?(URI::HTTP)
15
+ options[:puppetdb_url] = url
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # Quiet option
2
+ # @param parser [OptionParser object] The OptionParser argument
3
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
4
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:quiet) do
5
+ has_weight 120
6
+
7
+ def parse(parser, options)
8
+ parser.on('--[no-]quiet', '-q', 'Quiet (no status messages except errors)') do |x|
9
+ options[:quiet] = x
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # Transient errors can cause catalog compilation problems. This adds an option to retry
2
+ # a failed catalog multiple times before kicking out an error message.
3
+ # @param parser [OptionParser object] The OptionParser argument
4
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
5
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:retry_failed_catalog) do
6
+ has_weight 230
7
+
8
+ def parse(parser, options)
9
+ parser.on('--retry-failed-catalog N', OptionParser::DecimalInteger, 'Retry building a failed catalog N times') do |x|
10
+ options[:retry_failed_catalog] = x
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # By specifying a directory path here, you are explicitly giving permission to the program
2
+ # to delete it if it believes it needs to be created (e.g., if the SHA has changed of the
3
+ # cached directory).
4
+ # @param parser [OptionParser object] The OptionParser argument
5
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
6
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:safe_to_delete_cached_master_dir) do
7
+ has_weight 160
8
+
9
+ def parse(parser, options)
10
+ parser.on('--safe-to-delete-cached-master-dir PATH', 'OK to delete cached master directory at this path') do |path_in|
11
+ path = File.absolute_path(path_in)
12
+ options[:safe_to_delete_cached_master_dir] = path
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # Set storeconfigs (integration with PuppetDB for collected resources)
2
+ # @param parser [OptionParser object] The OptionParser argument
3
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
4
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:storeconfigs) do
5
+ has_weight 220
6
+
7
+ def parse(parser, options)
8
+ parser.on('--[no-]storeconfigs', 'Enable integration with puppetdb for collected resources') do |x|
9
+ options[:storeconfigs] = x
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ # If enabled, this option will suppress changes to certain attributes of a file, if the
2
+ # file is specified to be 'absent' in the target catalog. Suppressed changes in this case
3
+ # include user, group, mode, and content, because a removed file has none of those.
4
+ # @param parser [OptionParser object] The OptionParser argument
5
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
6
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:suppress_absent_file_details) do
7
+ has_weight 600
8
+
9
+ def parse(parser, options)
10
+ parser.on('--[no-]suppress-absent-file-details', 'Suppress certain attributes of absent files') do |x|
11
+ options[:suppress_absent_file_details] = x
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # Set the 'from' and 'to' branches, which is used to compile catalogs. A branch of '.' means to use
2
+ # the current contents of the base code directory without any git checkouts.
3
+ # @param parser [OptionParser object] The OptionParser argument
4
+ # @param options [Hash] Options hash being constructed; this is modified in this method.
5
+ OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:to_from_branch) do
6
+ has_weight 20
7
+
8
+ def parse(parser, options)
9
+ parser.on('--from FROM_BRANCH', '-f', 'Branch you are coming from') do |env|
10
+ options[:from_env] = env
11
+ end
12
+ parser.on('--to TO_BRANCH', '-t', 'Branch you are going to') do |env|
13
+ options[:to_env] = env
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,52 @@
1
+ require_relative '../display'
2
+
3
+ module OctocatalogDiff
4
+ module CatalogDiff
5
+ class Cli
6
+ # Wrapper around OctocatalogDiff::CatalogDiff::Display to set the options and
7
+ # output to a file or the screen depending on selection.
8
+ class Printer
9
+ # Class for thrown exceptions
10
+ class PrinterError < RuntimeError
11
+ end
12
+
13
+ # Constructor
14
+ # @param options [Hash] Options from cli/options
15
+ # @param logger [Logger] Logger object
16
+ def initialize(options, logger)
17
+ @options = options
18
+ @logger = logger
19
+ end
20
+
21
+ # The method to call externally, passing in diffs. This takes the appropriate action
22
+ # based on options, which is either to write the result into an output file, or print
23
+ # the result on STDOUT. Does not return anything.
24
+ # @param diffs [OctocatalogDiff::CatalogDiff::Differ] Difference array
25
+ # @param from_dir [String] Directory in which "from" catalog was compiled
26
+ # @param to_dir [String] Directory in which "to" catalog was compiled
27
+ def printer(diffs, from_dir = nil, to_dir = nil)
28
+ display_opts = @options.merge(compilation_from_dir: from_dir, compilation_to_dir: to_dir)
29
+ diff_text = OctocatalogDiff::CatalogDiff::Display.output(diffs, display_opts, @logger)
30
+ if @options[:output_file].nil?
31
+ puts diff_text unless diff_text.empty?
32
+ else
33
+ output_to_file(diff_text)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Output to a file, handling errors related to writing files.
40
+ # @param diff_in [String|Array] Text to write to file
41
+ def output_to_file(diff_in)
42
+ diff_text = diff_in.is_a?(Array) ? diff_in.join("\n") : diff_in
43
+ File.open(@options[:output_file], 'w') { |f| f.write(diff_text) }
44
+ @logger.info "Wrote diff to #{@options[:output_file]}"
45
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => exc
46
+ @logger.error "Cannot write to #{@options[:output_file]}: #{exc}"
47
+ raise PrinterError, "Cannot write to #{@options[:output_file]}: #{exc}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,615 @@
1
+ require 'diffy'
2
+ require 'hashdiff'
3
+ require 'json'
4
+ require 'set'
5
+ require 'stringio'
6
+
7
+ require_relative '../catalog'
8
+
9
+ module OctocatalogDiff
10
+ module CatalogDiff
11
+ # Calculate the difference between two Puppet catalogs.
12
+ # -----------------------------------------------------
13
+ # It was necessary to write our own code for this, and not just use some existing gem,
14
+ # for two main reasons:
15
+ #
16
+ # 1. There are things that we want to ignore when doing a Puppet catalog diff. For example
17
+ # we want to ignore 'before' and 'require' parameters (because those affect the order of
18
+ # operations only, not the end result) and we probably want to ignore 'tags' attributes
19
+ # and all classes. No existing code (that I could find at least) was capable of allowing
20
+ # you to skip stuff via arguments, without your own custom pre-processing.
21
+ #
22
+ # 2. When using the 'hashdiff' gem, there is no distinguishing between an addition of an entire
23
+ # new key-value pair, or the addition of an element in a deeply nested array. By way of
24
+ # further explanation, consider these two data structures:
25
+ #
26
+ # a = { 'foo' => 'bar', 'my_array' => [ 1, 2, 3 ] }
27
+ # b = { 'foo' => 'bar', 'my_array' => [ 1, 2, 3, 4 ], 'another_key' => 'another_value'
28
+ #
29
+ # The hashdiff gem would report the differences between a and b to be:
30
+ # + 4
31
+ # + another_key => another_value
32
+ #
33
+ # We want to distinguish (without a whole bunch of convoluted code) between these two situations.
34
+ # One was a true addition (adding a key) while one was a change (adding element to array). This
35
+ # distinction becomes even more important when considering top-level changes vs. changes to arrays
36
+ # or hashes nested within the catalog.
37
+ #
38
+ # Therefore, the algorithm implemented here is as follows:
39
+ #
40
+ # 1. Pre-process the catalog JSON files to:
41
+ # - Sort the 'tags' array, since the order of tags does not matter to Puppet
42
+ # - Pull out additions of entire key-value pairs (above, 'another_key' => 'another_value')
43
+ #
44
+ # 2. Everything left consists of key-value pairs where the key exists in both old and new. Pass this
45
+ # to the 'hashdiff' gem.
46
+ #
47
+ # 3. Filter any differences to remove attributes, types, or resources that have been explicitly ignored.
48
+ #
49
+ # 4. Reformat any '+' or '-' reported by hashdiff to be changes to the keys, rather than outright
50
+ # additions.
51
+ #
52
+ # The heavy lifting is still handled by 'hashdiff' but we're pre-simplifying the input and post-processing
53
+ # the output to make it easier to deal with later.
54
+ class Differ
55
+ # This class is to distinguish handled errors from unhandled ones, for spec testing.
56
+ class DifferError < RuntimeError
57
+ end
58
+
59
+ # Constructor
60
+ # @param catalog1_in [OctocatalogDiff::Catalog] First catalog to compare
61
+ # @param catalog2_in [OctocatalogDiff::Catalog] Second catalog to compare
62
+ def initialize(opts, catalog1_in, catalog2_in)
63
+ @catalog1 = catalog_resources(catalog1_in, 'First catalog')
64
+ @catalog2 = catalog_resources(catalog2_in, 'Second catalog')
65
+ @logger = opts.fetch(:logger, Logger.new(StringIO.new))
66
+ @diff_result = nil
67
+ @ignore = Set.new
68
+ ignore(opts.fetch(:ignore, []))
69
+ @opts = opts
70
+ end
71
+
72
+ # Difference - calculates and then returns the diff of this objects
73
+ # Each diff result is an array like this:
74
+ # [ <String> '+|-|~|!', <String> Key name, <Object> Old object, <Object> New object ]
75
+ # @return [Array<Diff results>] Results of the diff
76
+ def diff
77
+ @diff_result ||= catdiff
78
+ end
79
+
80
+ # Ignore - ignored items can be set by Type, Title, or Attribute; setting multiple in
81
+ # a hash is interpreted as AND. The collection of all ignored items is interpreted as OR.
82
+ # @param ignore [Hash<type: xxx, title: yyy, attr: zzz>] Ignore type/title/attr (can pass array also)
83
+ # @return [OctocatalogDiff::CatalogDiff::Differ] This object, modified
84
+ def ignore(ignores = [])
85
+ ignore_array = ignores.is_a?(Array) ? ignores : [ignores]
86
+ ignore_array.each do |item|
87
+ raise ArgumentError, "Argument #{item.inspect} to ignore is not a hash" unless item.is_a?(Hash)
88
+ unless item.key?(:type) || item.key?(:title) || item.key?(:attr)
89
+ raise ArgumentError, "Argument #{item.inspect} does not contain :type, :title, or :attr"
90
+ end
91
+ item[:type] ||= '*'
92
+ item[:title] ||= '*'
93
+ item[:attr] ||= '*'
94
+
95
+ # Support wildcards in title
96
+ if item[:title].is_a?(String) && item[:title] != '*' && item[:title].include?('*')
97
+ item[:title] = Regexp.new("\\A#{Regexp.escape(item[:title]).gsub('\*', '.*')}\\Z", 'i')
98
+ end
99
+
100
+ @ignore.add(item)
101
+ end
102
+ self
103
+ end
104
+
105
+ # Return catalog1 with filter_and_cleanups applied.
106
+ # This is in the public section because it's called from spec tests as well
107
+ # as being called internally.
108
+ # @return [Array<Resource Hashes>] Filtered resources in catalog
109
+ def catalog1
110
+ filter_and_cleanup(@catalog1)
111
+ end
112
+
113
+ # Return catalog2 with filter_and_cleanups applied.
114
+ # This is in the public section because it's called from spec tests as well
115
+ # as being called internally.
116
+ # @return [Array<Resource Hashes>] Filtered resources in catalog
117
+ def catalog2
118
+ filter_and_cleanup(@catalog2)
119
+ end
120
+
121
+ private
122
+
123
+ # Actually perform the catalog diff. This implements the 3-part algorithm described in the
124
+ # comment block at the top of this file.
125
+ def catdiff
126
+ @logger.debug "Entering catdiff; catalog sizes: #{@catalog1.size}, #{@catalog2.size}"
127
+
128
+ # Compute '+' and '-' from resources that exist in one catalog but not another.
129
+ # After this returns,
130
+ # result = Array<'+|-', key, value> (Additions/subtractions of entire resources)
131
+ # remaining1 & remaining2 = Hash<Serialized Type+Title, Value> (resources in each catalog)
132
+ # Note that remaining1.keys == remaining2.keys after running this
133
+ result, remaining1, remaining2 = preprocess_diff
134
+
135
+ # Call the hashdiff gem.
136
+ # After this returns,
137
+ # initial_hashdiff_result = Array<'~', key, oldvalue, newvalue>
138
+ # hashdiff_add_remove = Array<Serialized tokens with nested changes>
139
+ initial_hashdiff_result, hashdiff_add_remove = hashdiff_initial(remaining1, remaining2)
140
+ result.concat initial_hashdiff_result
141
+
142
+ # Compute '!' which is elements of arrays or hashes within the 'hashdiff' change set that
143
+ # have been added. See explanation in point #2 in main comment block at the top of this file.
144
+ hashdiff_nested_changes_result = hashdiff_nested_changes(hashdiff_add_remove, remaining1, remaining2)
145
+ result.concat hashdiff_nested_changes_result
146
+
147
+ # Remove resources that have been explicitly ignored
148
+ filter_diffs_for_ignored_items(result)
149
+
150
+ # If a file has ensure => absent, there are certain parameters that don't matter anymore. Filter
151
+ # out any such parameters from the result array.
152
+ filter_diffs_for_absent_files(result) if @opts[:suppress_absent_file_details]
153
+
154
+ # That's it!
155
+ @logger.debug "Exiting catdiff; change count: #{result.size}"
156
+ result
157
+ end
158
+
159
+ # Filter the differences for any items that were ignored, by some combination of type, title, and
160
+ # attribute. This modifies the array itself by selecting only items that do not meet the ignored
161
+ # filter.
162
+ def filter_diffs_for_ignored_items(result)
163
+ result.reject! { |item| ignored?(item) }
164
+ end
165
+
166
+ # If a file has ensure => absent, there are certain parameters that don't matter anymore. Filter
167
+ # out any such parameters from the result array.
168
+ # @param result [Array] Diff result list (modified by this method)
169
+ def filter_diffs_for_absent_files(result)
170
+ @logger.debug "Entering filter_diffs_for_absent_files with #{result.size} diffs"
171
+
172
+ # Scan for files in the result that are file resources with ensure => absent.
173
+ absent_files = Set.new
174
+ result.each do |diff|
175
+ next unless diff[0] == '~' || diff[0] == '!'
176
+ next unless diff[1] =~ /^File\f(.+)\fparameters\fensure$/
177
+ next unless ['absent', 'false', false].include?(diff[3])
178
+ absent_files.add Regexp.last_match(1)
179
+ end
180
+
181
+ # If there are any absent files, remove all diffs referencing that file, except for
182
+ # the change to 'ensure'.
183
+ if absent_files.any?
184
+ keep = %w(ensure backup force provider)
185
+ result.map! do |diff|
186
+ if (diff[0] == '!' || diff[0] == '~') && diff[1] =~ /^File\f(.+)\fparameters\f(.+)$/
187
+ if absent_files.include?(Regexp.last_match(1)) && !keep.include?(Regexp.last_match(2))
188
+ @logger.debug "Removing file=#{Regexp.last_match(1)} parameter=#{Regexp.last_match(2)} for absent file"
189
+ nil
190
+ else
191
+ diff
192
+ end
193
+ else
194
+ diff
195
+ end
196
+ end
197
+ result.compact!
198
+ end
199
+
200
+ @logger.debug "Exiting filter_diffs_for_absent_files with #{result.size} diffs"
201
+ end
202
+
203
+ # Pre-processing of a catalog.
204
+ # - Remove 'before' and 'require' from parameters
205
+ # - Sort 'tags' array, or remove the tags array if tags are being ignored
206
+ # @param catalog_resources [Array<Hash>] Catalog resources
207
+ # @return [Array<Hash>] Array of cleaned resources
208
+ def filter_and_cleanup(catalog_resources)
209
+ result = []
210
+ catalog_resources.each do |resource|
211
+ # Exported resources are skipped (this is specifically testing that the value is
212
+ # equal to the boolean true, not just that the value exists or something similar)
213
+ next if resource['exported'] == true
214
+
215
+ # This will be the modified hash added to result
216
+ hsh = {}
217
+ hsh['type'] = resource.fetch('type', '')
218
+ hsh['title'] = resource.fetch('title', '')
219
+
220
+ # Special case for something like:
221
+ # file { 'my-own-resource-name':
222
+ # path => '/var/lib/puppet/my-file.txt'
223
+ # }
224
+ #
225
+ # The catalog-diff will treat the file above as "File\f/var/lib/puppet/my-file.txt" since the
226
+ # name that was given to the resource has no effect on how the file is deployed.
227
+ #
228
+ # Note that if the file was specified like this:
229
+ # file { '/var/lib/puppet/my-file.txt': }
230
+ #
231
+ # That also is "File\f/var/lib/puppet/my-file.txt" and that's what we want.
232
+ if resource.fetch('type', '') == 'File' && resource.key?('parameters') && resource['parameters'].key?('path')
233
+ hsh['title'] = resource['parameters']['path']
234
+ resource['parameters'].delete('path')
235
+ end
236
+
237
+ # Process each attribute in the resource
238
+ resource.each do |k, v|
239
+ # Title was pre-processed
240
+ next if k == 'title' || k == 'type'
241
+
242
+ # Handle parameters
243
+ if k == 'parameters'
244
+ cleansed_param = cleanse_parameters_hash(v)
245
+ hsh[k] = cleansed_param unless cleansed_param.nil? || cleansed_param.empty?
246
+ elsif k == 'tags'
247
+ # The order of tags is unimportant. Sort this array to avoid false diffs if order changes.
248
+ # Also if tags is empty, don't add. Most uses of catalog diff will want to ignore tags,
249
+ # and if you're ignoring tags you won't get here anyway. Also, don't add empty array of tags.
250
+ unless @opts[:ignore_tags]
251
+ hsh[k] = v.sort if v.is_a?(Array) && v.any?
252
+ end
253
+ elsif k == 'file' || k == 'line'
254
+ # We don't care, for the purposes of catalog-diff, from which manifest and line this resource originated.
255
+ # However, we may report this to the user, so we will keep it in here for now.
256
+ hsh[k] = v
257
+ else
258
+ # Default case: just use the existing value as-is.
259
+ hsh[k] = v
260
+ end
261
+ end
262
+
263
+ result << hsh unless hsh.empty?
264
+ end
265
+ result
266
+ end
267
+
268
+ # Logic to match attribute regular expressions. Called by lambda function in attr_match_rule?.
269
+ # @param operator [String] Either =~> (any regexp match) or =&> (all diffs must match regexp)
270
+ # @param regex [Regexp] Regex object
271
+ # @param old_val [String] Value from first catalog
272
+ # @param new_val [String] Value from first catalog
273
+ # @return [Boolean] True if condition is satisfied, false otherwise
274
+ def regexp_operator_match?(operator, regex, old_val, new_val)
275
+ # Use diffy to get only the lines that have changed in a text object.
276
+ # As we iterate through the diff, jump out if we have our answer: either
277
+ # true if '=~>' finds ANY match, or false if '=&>' fails to find a match.
278
+ Diffy::Diff.new(old_val, new_val, context: 0).each do |line|
279
+ if regex.match(line.strip)
280
+ return true if operator == '=~>'
281
+ elsif operator == '=&>'
282
+ return false
283
+ end
284
+ end
285
+
286
+ # At this point, we did not return out of the loop early. This means that for
287
+ # '=~>' no matches were found at all, so we should return false. Or for '=&>'
288
+ # every diff matched, so we should return true.
289
+ operator == '=~>' ? false : true
290
+ end
291
+
292
+ # Determine whether a particular attribute matches a rule
293
+ # @param rule [Hash] Rule
294
+ # @param attrib [String] String representation of attribute
295
+ # @param old_val [?] Old value
296
+ # @param new_val [?] New value
297
+ # @return [Boolean] True if attribute matches rule
298
+ def attr_match_rule?(rule, attrib, old_val, new_val)
299
+ matcher = ->(_x, _y) { true }
300
+ rule_attr = rule[:attr].dup
301
+
302
+ # Start with '+' or '-' indicates attribute was added or removed
303
+ if rule_attr.start_with?('+')
304
+ return false unless old_val.nil?
305
+ rule_attr.sub!(/^\+/, '')
306
+ elsif rule_attr.start_with?('-')
307
+ return false unless new_val.nil?
308
+ rule_attr.sub!(/^-/, '')
309
+ end
310
+
311
+ # Conditions that match the attribute value or regular expression
312
+ # Operators supported include:
313
+ # => String equality
314
+ # =+> Attribute must have been added and equal this
315
+ # =-> Attribute must have been removed and equal this
316
+ # =~> Change must match regexp (one line of change matching is sufficient)
317
+ # =&> Change must match regexp (all lines of change MUST match regexp)
318
+ if rule_attr =~ /\A(.+?)(=[\-\+~&]?>)(.+)/m
319
+ rule_attr = Regexp.last_match(1)
320
+ operator = Regexp.last_match(2)
321
+ value = Regexp.last_match(3)
322
+ if operator == '=>'
323
+ # String equality test
324
+ matcher = ->(x, y) { x == value || y == value }
325
+ elsif operator == '=+>'
326
+ # String equality test only of the new value
327
+ matcher = ->(_x, y) { y == value }
328
+ elsif operator == '=->'
329
+ # String equality test only of the old value
330
+ matcher = ->(x, _y) { x == value }
331
+ elsif operator == '=~>' || operator == '=&>'
332
+ begin
333
+ my_regex = Regexp.new(value, Regexp::IGNORECASE)
334
+ rescue RegexpError => exc
335
+ key = "#{rule[:type]}[#{rule[:title]}] #{rule_attr.gsub(/\f/, '::')} =~ #{value}"
336
+ raise RegexpError, "Invalid ignore regexp for #{key}: #{exc.message}"
337
+ end
338
+ matcher = ->(x, y) { regexp_operator_match?(operator, my_regex, x, y) }
339
+ end
340
+ end
341
+
342
+ if rule_attr =~ /\f/
343
+ beginning = rule_attr.start_with?("\f") ? '\A' : '(\A|\f)'
344
+ ending = '(\f|\Z)'
345
+ rule_attr.gsub!(/^\f+/, '')
346
+ hash_attr_regexp = Regexp.new(beginning + Regexp.escape(rule_attr) + ending, Regexp::IGNORECASE)
347
+ return attrib.match(hash_attr_regexp) && matcher.call(old_val, new_val)
348
+ else
349
+ s = attrib.downcase.split(/\f/)
350
+ return s.include?(rule_attr.downcase) && matcher.call(old_val, new_val)
351
+ end
352
+ end
353
+
354
+ # Determine if a particular item matches a particular ignore pattern
355
+ # @param rule [Hash] Ignore rule
356
+ # @param diff_type [String] One of +, -, ~, !
357
+ # @param hsh [Hash] { type: title: attr: } parsed resource name
358
+ # @param old_val [?] Old value
359
+ # @param new_val [?] New value
360
+ # @return [Boolean] True if the item matched the rule
361
+ def ignore_match?(rule_in, diff_type, hsh, old_val, new_val)
362
+ rule = rule_in.dup
363
+
364
+ # Type matches?
365
+ return false unless rule[:type] == '*' || rule[:type].casecmp(hsh[:type]).zero?
366
+
367
+ # Title matches? (Support regexp and string)
368
+ if rule[:title].is_a?(Regexp)
369
+ return false unless hsh[:title].match(rule[:title])
370
+ elsif rule[:title] != '*'
371
+ return false unless rule[:title].casecmp(hsh[:title]).zero?
372
+ end
373
+
374
+ # Special 'attributes': Ignore specific diff types (+ add, - remove, ~ and ! change)
375
+ if rule[:attr] =~ /\A[\-\+~!]+\Z/
376
+ return ignore_match_true(hsh, rule) if rule[:attr].include?(diff_type)
377
+ return false
378
+ end
379
+
380
+ # Attribute matches?
381
+ return ignore_match_true(hsh, rule) if hsh[:attr].nil? && rule[:attr].nil?
382
+ return ignore_match_true(hsh, rule) if rule[:attr] == '*'
383
+ return false if hsh[:attr].nil?
384
+
385
+ # Attributes that match values
386
+ if rule[:attr].is_a?(Array)
387
+ rule[:attr].each do |attrib|
388
+ return false unless attr_match_rule?(rule.merge(attr: attrib), hsh[:attr], old_val, new_val)
389
+ end
390
+ else
391
+ return false unless attr_match_rule?(rule, hsh[:attr], old_val, new_val)
392
+ end
393
+
394
+ # Still here? Must be true.
395
+ ignore_match_true(hsh, rule)
396
+ end
397
+
398
+ # Debugging for ignore_match: This logs a debug message for an ignored diff and then returns true.
399
+ # @param hsh [Hash] Item that is being checked
400
+ # @param rule [Hash] Ignore rule
401
+ # @return [Boolean] Always returns true
402
+ def ignore_match_true(hsh, rule)
403
+ @logger.debug "Ignoring #{hsh.inspect}, matches #{rule.inspect}"
404
+ true
405
+ end
406
+
407
+ # Determine if a given item is ignored
408
+ # @param diff [Array] Diff
409
+ # @return [Boolean] True to ignore resource, false not to ignore
410
+ def ignored?(diff)
411
+ key = diff[1]
412
+ hsh = if key =~ /\A([^\f]+)\f([^\f]+)\Z/
413
+ { type: Regexp.last_match(1), title: Regexp.last_match(2) }
414
+ else
415
+ s = key.split(/\f/, 3)
416
+ { type: s[0], title: s[1], attr: s[2] }
417
+ end
418
+ @ignore.each do |rule|
419
+ return true if ignore_match?(rule, diff[0], hsh, diff[2], diff[3])
420
+ end
421
+ false
422
+ end
423
+
424
+ # Cleanse parameters of filtered attributes.
425
+ # @param parameters_hash [Hash] Hash of parameters
426
+ # @return [Hash] Cleaned parameters hash (original input hash is not altered)
427
+ def cleanse_parameters_hash(parameters_hash)
428
+ result = parameters_hash.dup
429
+
430
+ # 'before' and 'require' handle internal Puppet ordering but do not affect what
431
+ # happens on the target machine. Don't consider these for the purpose of catalog diff.
432
+ result.delete('before')
433
+ result.delete('require')
434
+
435
+ # Sort arrays for parameters where the order is unimportant
436
+ %w(notify subscribe tag).each { |key| result[key].sort! if result[key].is_a?(Array) }
437
+
438
+ # Return the result
439
+ result
440
+ end
441
+
442
+ # Pre-process catalog resources by looking for additions and removals. This is required to distinguish between
443
+ # top-level addition/removal of resources, and addition/removal of elements from arrays and hashes nested within
444
+ # resources (those too will be reported as +/- by hashdiff, but we want to see them as changes).
445
+ # @return [Array<['+|-', Key, Hash]>, Array<(catalog1 hashes)>, Array<(catalog2 hashes)>] Data
446
+ def preprocess_diff
447
+ @logger.debug "Entering preprocess_diff; catalog sizes: #{@catalog1.size}, #{@catalog2.size}"
448
+
449
+ # Do the pre-processing: filter_and_cleanup catalogs of resources that do not matter, and then run
450
+ # through each to tokenize the entries for initial comparison.
451
+ # NOTE: 'catalog1' and 'catalog2' are methods above that call filter_and_cleanup(@catalogX)
452
+
453
+ catalog1_result = resources_as_hashes_with_serialized_keys(catalog1)
454
+ catalog1_resources = catalog1_result[:catalog]
455
+
456
+ catalog2_result = resources_as_hashes_with_serialized_keys(catalog2)
457
+ catalog2_resources = catalog2_result[:catalog]
458
+
459
+ # Call out all added and removed keys, and delete these from further consideration.
460
+ # (That way, 'hashdiff' will only be used to compare keys existing in both old and new.)
461
+ result = []
462
+ added_keys = catalog2_resources.keys - catalog1_resources.keys
463
+ removed_keys = catalog1_resources.keys - catalog2_resources.keys
464
+
465
+ added_keys.each do |key|
466
+ key_for_map = key.split(/\f/, 3)[0..1].join("\f") # Keep first two values separated by \f
467
+ result << ['+', key, catalog2_resources[key], catalog2_result[:catalog_map][key_for_map]]
468
+ catalog2_resources.delete(key)
469
+ end
470
+
471
+ removed_keys.each do |key|
472
+ key_for_map = key.split(/\f/, 3)[0..1].join("\f") # Keep first two values separated by \f
473
+ result << ['-', key, catalog1_resources[key], catalog1_result[:catalog_map][key_for_map]]
474
+ catalog1_resources.delete(key)
475
+ end
476
+
477
+ @logger.debug "Exiting preprocess_diff; added #{added_keys.size}, removed #{removed_keys.size}"
478
+ [result, catalog1_result, catalog2_result]
479
+ end
480
+
481
+ # This runs the remaining resources in the catalogs through hashdiff.
482
+ # @param catalog1_resources [<Hash<Catalog Resources, Catalog Map>] Hash of catalog1's resources, tokenized
483
+ # @param catalog2_resources [<Hash<Catalog Resources, Catalog Map>] Hash of catalog2's resources, tokenized
484
+ # @return [Array<Differences>, Array<(Token, Old, New)>] Input to next step
485
+ def hashdiff_initial(catalog1_in, catalog2_in)
486
+ catalog1_resources = catalog1_in[:catalog]
487
+ catalog2_resources = catalog2_in[:catalog]
488
+
489
+ @logger.debug "Entering hashdiff_initial; catalog sizes: #{catalog1_resources.size}, #{catalog2_resources.size}"
490
+ result = []
491
+ hashdiff_add_remove = Set.new
492
+ hashdiff_result = HashDiff.diff(catalog1_resources, catalog2_resources, delimiter: "\f")
493
+ hashdiff_result.each do |obj|
494
+ # Regular change
495
+ if obj[0] == '~'
496
+ key_for_map = obj[1].split(/\f/, 3)[0..1].join("\f") # Keep first two values separated by \f
497
+ obj << catalog1_in[:catalog_map][key_for_map]
498
+ obj << catalog2_in[:catalog_map][key_for_map]
499
+ result << obj
500
+ next
501
+ end
502
+
503
+ # Added/removed element to/from array
504
+ if obj[1] =~ /^(.+)\[\d+\]/
505
+ hashdiff_add_remove.add(Regexp.last_match(1))
506
+ next
507
+ end
508
+
509
+ # Added a new key that points to some kind of data structure that we know how
510
+ # to handle.
511
+ if obj[1] =~ /^(.+)\f([^\f]+)$/ && [String, Fixnum, Float, TrueClass, FalseClass, Array, Hash].include?(obj[2].class)
512
+ hashdiff_add_remove.add(obj[1])
513
+ next
514
+ end
515
+
516
+ # Any other weird edge cases need to be added and handled here. For now just error out.
517
+ # :nocov:
518
+ raise "Bug (please report): Unexpected data structure in hashdiff_result: #{obj.inspect}"
519
+ # :nocov:
520
+ end
521
+ @logger.debug "Exiting hashdiff_initial; changes: #{result.size}, nested changes: #{hashdiff_add_remove.size}"
522
+ [result, hashdiff_add_remove.to_a]
523
+ end
524
+
525
+ # This diffs nested changes deep in the data structure. Each item in hashdiff_add_remove
526
+ # has been previously identified as being an addition or removal from a deeply nested element
527
+ # that exists in both old and new. This code compares that deeply nested element in both the
528
+ # old and new, and uses status '!' (rather than '+', '-', or '~') to indicate that the change
529
+ # occurred in a deeply nested element.
530
+ # @param hashdiff_add_remove [Array<Serialized Tokens>] Adds/removes from hashdiff
531
+ # @param remaining1 [Hash<Catalog1 Resources>] Serialized key / value pairs for catalog1 resources
532
+ # @param remaining2 [Hash<Catalog1 Resources>] Serialized key / value pairs for catalog2 resources
533
+ # @return [Array<'!', key, old, new>] Change set
534
+ def hashdiff_nested_changes(hashdiff_add_remove, remaining1, remaining2)
535
+ return [] if hashdiff_add_remove.empty?
536
+
537
+ catalog1 = remaining1[:catalog]
538
+ catalog2 = remaining2[:catalog]
539
+ catmap1 = remaining1[:catalog_map]
540
+ catmap2 = remaining2[:catalog_map]
541
+ result = []
542
+
543
+ hashdiff_add_remove.each do |key|
544
+ key_split = key.split(/\f/)
545
+ first_part_of_key = [key_split.shift, key_split.shift].join("\f")
546
+ key_split.unshift first_part_of_key
547
+ if catalog1[first_part_of_key].is_a?(Hash) && catalog2[first_part_of_key].is_a?(Hash)
548
+ # At this point catalog1[first_part_of_key] might look like this:
549
+ # {
550
+ # "type"=>"Class",
551
+ # "title"=>"Openssl::Package",
552
+ # "exported"=>false,
553
+ # "parameters"=>{"openssl_version"=>"1.0.1-4", "common-array"=>[1, 3, 5]}
554
+ # }
555
+ # and key_split looks like this:
556
+ # [ "Class\fOpenssl::Package", 'parameters', 'common-array' ]
557
+ #
558
+ # We have to dig out remaining1["Class\fOpenssl::Package"]['parameters']['common-array']
559
+ # to do the comparison.
560
+ obj0 = dig_out_key(catalog1, key_split.dup)
561
+ obj1 = dig_out_key(catalog2, key_split.dup)
562
+ result << ['!', key, obj0, obj1, catmap1[first_part_of_key], catmap2[first_part_of_key]]
563
+ else
564
+ # Bug condition
565
+ # :nocov:
566
+ raise "BUG (Please report): Unexpected resource: #{first_part_of_key.inspect} not a catalog resource"
567
+ # :nocov:
568
+ end
569
+ end
570
+ result
571
+ end
572
+
573
+ # From an array of keys [key1, key2, key3, ...] dig out the value of hash[key1][key2][key3]...
574
+ # @param hash_in [Hash] Starting hash (or value passed in by recursion)
575
+ # @param key_array [Array<String>] Names of keys in order
576
+ # @return [?] Value of hash_in[key1][key2][key3]..., or nil if any keys along the way don't exist
577
+ def dig_out_key(hash_in, key_array)
578
+ return hash_in if key_array.empty?
579
+ return hash_in unless hash_in.is_a?(Hash)
580
+ return nil unless hash_in.key?(key_array[0])
581
+ next_key = key_array.shift
582
+ dig_out_key(hash_in[next_key], key_array)
583
+ end
584
+
585
+ # This is a helper for the constructor, verifying that the incoming catalog is an expected
586
+ # object.
587
+ # @param catalog [OctocatalogDiff::Catalog] Incoming catalog
588
+ # @return [Hash] Internal simplified hash object
589
+ def catalog_resources(catalog_in, name = 'Passed catalog')
590
+ return catalog_in.resources if catalog_in.is_a?(OctocatalogDiff::Catalog)
591
+ raise DifferError, "#{name} is not a valid catalog (input datatype: #{catalog_in.class})"
592
+ end
593
+
594
+ # Turn array of resources into a hash by serialized keys. For consistency with 'hashdiff'
595
+ # the serialized key is the resource type and all components of the title (split on '::'),
596
+ # joined with \f.
597
+ # @param catalog Array<Hash> Resource array from catalog
598
+ # @return [Hash] See description above
599
+ def resources_as_hashes_with_serialized_keys(catalog)
600
+ result = {
601
+ catalog: {},
602
+ catalog_map: {}
603
+ }
604
+ catalog.each do |item|
605
+ i = item.dup
606
+ result[:catalog_map]["#{item['type']}\f#{item['title']}"] = { 'file' => item['file'], 'line' => item['line'] }
607
+ i.delete('file')
608
+ i.delete('line')
609
+ result[:catalog]["#{item['type']}\f#{item['title']}"] = i
610
+ end
611
+ result
612
+ end
613
+ end
614
+ end
615
+ end