octocatalog-diff 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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