diff --git a/.version b/.version index a918a2aa..ee6cdce3 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index ce3a69bf..e1696b06 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -8,6 +8,15 @@ +0.6.1 +2017-01-07 + + + + + 0.6.0 2017-01-04 diff --git a/doc/advanced-filter.md b/doc/advanced-filter.md new file mode 100644 index 00000000..7938a609 --- /dev/null +++ b/doc/advanced-filter.md @@ -0,0 +1,26 @@ +# Additional output filters + +It is possible to enable additional filters for output results via the `--filters` command line option. This command line option accepts a comma-separated list of additional filters, and applies them to the results in the order you specify. The default behavior is not to use any of these filters. + +Please note that there are other options to ignore specified diffs, including: + +- [Ignoring certain changes via command line options](/doc/advanced-ignores.md) +- [Dynamic ignoring of changes via tags in Puppet manifests](/doc/advanced-dynamic-ignores.md) + +Here is the list of available filters and an explanation of each: + +- [YAML](#YAML) - Ignore whitespace/comment differences if YAML parses to the same object + +## YAML + +#### Usage + +``` +--filters YAML +``` + +#### Description + +If a file resource has extension `.yml` or `.yaml` and a difference in its content is observed, YAML objects are constructed from the previous and new values. If these YAML objects are identical, the difference is ignored. + +This allows you to ignore changes in whitespace, comments, etc., that are not meaningful to a machine parsing the file. Please note that by filtering these changes, you are ignoring changes to comments, which may be meaningful to humans. diff --git a/doc/advanced.md b/doc/advanced.md index 8b94bec1..8d5d01c8 100644 --- a/doc/advanced.md +++ b/doc/advanced.md @@ -27,6 +27,7 @@ See also: ### Controlling output - [Ignoring certain changes via command line options](/doc/advanced-ignores.md) +- [Additional output filters](/doc/advanced-filter.md) - [Dynamic ignoring of changes via tags in Puppet manifests](/doc/advanced-dynamic-ignores.md) - [Output formats](/doc/advanced-output-formats.md) - [Useful output hacks](/doc/advanced-output-hacks.md) diff --git a/doc/optionsref.md b/doc/optionsref.md index 34d43a52..4e72b19d 100644 --- a/doc/optionsref.md +++ b/doc/optionsref.md @@ -46,6 +46,8 @@ Usage: octocatalog-diff [command line options] --no-hiera-path-strip Do not use any default hiera path strip settings --ignore-attr "attr1,attr2,..." Attributes to ignore + --filters FILTER1[,FILTER2[,...]] + Filters to apply --[no-]display-source Show source file and line for each difference --[no-]validate-references "before,require,subscribe,notify" References to validate @@ -424,6 +426,20 @@ on which this is running. (filters.rb) + + +
-f FROM_BRANCH
diff --git a/lib/octocatalog-diff/catalog-diff/cli/options/filters.rb b/lib/octocatalog-diff/catalog-diff/cli/options/filters.rb
new file mode 100644
index 00000000..a998c56e
--- /dev/null
+++ b/lib/octocatalog-diff/catalog-diff/cli/options/filters.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Specify one or more filters to apply to the results of the catalog difference.
+# For a list of available filters and further explanation, please refer to
+# [Filtering results](/doc/advanced-filter.md).
+# @param parser [OptionParser object] The OptionParser argument
+# @param options [Hash] Options hash being constructed; this is modified in this method.
+OctocatalogDiff::CatalogDiff::Cli::Options::Option.newoption(:filters) do
+  has_weight 199
+
+  def parse(parser, options)
+    parser.on('--filters FILTER1[,FILTER2[,...]]', Array, 'Filters to apply') do |x|
+      options[:filters] ||= []
+      options[:filters].concat x
+    end
+  end
+end
diff --git a/lib/octocatalog-diff/catalog-diff/differ.rb b/lib/octocatalog-diff/catalog-diff/differ.rb
index 206c2d2c..3805779d 100644
--- a/lib/octocatalog-diff/catalog-diff/differ.rb
+++ b/lib/octocatalog-diff/catalog-diff/differ.rb
@@ -7,6 +7,7 @@
 require 'stringio'
 
 require_relative '../catalog'
+require_relative 'filter'
 
 module OctocatalogDiff
   module CatalogDiff
@@ -153,6 +154,9 @@ def catdiff
         # out any such parameters from the result array.
         filter_diffs_for_absent_files(result) if @opts[:suppress_absent_file_details]
 
+        # Apply any additional pluggable filters.
+        OctocatalogDiff::CatalogDiff::Filter.apply_filters(result, @opts[:filters])
+
         # That's it!
         @logger.debug "Exiting catdiff; change count: #{result.size}"
         result
@@ -175,7 +179,7 @@ def filter_diffs_for_absent_files(result)
         absent_files = Set.new
         result.each do |diff|
           next unless diff[0] == '~' || diff[0] == '!'
-          next unless diff[1] =~ /^File\f(.+)\fparameters\fensure$/
+          next unless diff[1] =~ /^File\f([^\f]+)\fparameters\fensure$/
           next unless ['absent', 'false', false].include?(diff[3])
           absent_files.add Regexp.last_match(1)
         end
diff --git a/lib/octocatalog-diff/catalog-diff/filter.rb b/lib/octocatalog-diff/catalog-diff/filter.rb
new file mode 100644
index 00000000..69e12932
--- /dev/null
+++ b/lib/octocatalog-diff/catalog-diff/filter.rb
@@ -0,0 +1,38 @@
+require_relative 'filter/yaml'
+
+module OctocatalogDiff
+  module CatalogDiff
+    # Filtering of diffs, and parent class for inheritance.
+    class Filter
+      # Public: Apply multiple filters by repeatedly calling the `filter` method for each
+      # filter in an array. This method returns nothing.
+      #
+      # @param result [Array] Difference array (mutated)
+      # @param filter_names [Array] Filters to run
+      # @param options [Hash] Options for each filter (hashed by name)
+      def self.apply_filters(result, filter_names, options = {})
+        return unless filter_names.is_a?(Array)
+        filter_names.each { |x| filter(result, x, options[x] || {}) }
+      end
+
+      # Public: Perform a filter on `result` using the specified filter class.
+      # This mutates `result` by removing items that are ignored. This method
+      # returns nothing.
+      #
+      # @param result [Array] Difference array (mutated)
+      # @param filter_class_name [String] Filter class name (from `filter` subdirectory)
+      # @param options [Hash] Additional options (optional) to pass to filtered? method
+      def self.filter(result, filter_class_name, options = {})
+        filter_class_name = [name.to_s, filter_class_name].join('::')
+        clazz = Kernel.const_get(filter_class_name)
+        result.reject! { |item| clazz.filtered?(item, options) }
+      end
+
+      # Inherited: Construct a default `filtered?` method for the subclass via inheritance.
+      # Each subclass must implement this method, so the default method errors.
+      def self.filtered?(_item, _options = {})
+        raise "No `filtered?` method is implemented in #{name}"
+      end
+    end
+  end
+end
diff --git a/lib/octocatalog-diff/catalog-diff/filter/yaml.rb b/lib/octocatalog-diff/catalog-diff/filter/yaml.rb
new file mode 100644
index 00000000..6e845af7
--- /dev/null
+++ b/lib/octocatalog-diff/catalog-diff/filter/yaml.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module OctocatalogDiff
+  module CatalogDiff
+    class Filter
+      # Filter based on equivalence of YAML objects for file resources with named extensions.
+      class YAML < OctocatalogDiff::CatalogDiff::Filter
+        # Public: Actually do the comparison of YAML objects for appropriate resources.
+        # Return true if the YAML objects are known to be equivalent. Return false if they
+        # are not equivalent, or if equivalence cannot be determined.
+        #
+        # @param diff [Array] Difference
+        # @param _options [Hash] Additional options (there are none for this filter)
+        # @return [Boolean] true if this difference is a YAML file with identical objects, false otherwise
+        def self.filtered?(diff, _options = {})
+          # Skip additions or removals - focus only on changes
+          return false unless diff[0] == '~' || diff[0] == '!'
+
+          # Make sure we are comparing file content for a file ending in .yaml or .yml extension
+          return false unless diff[1] =~ /^File\f([^\f]+)\.ya?ml\fparameters\fcontent$/
+
+          # Attempt to convert the old (diff[2]) and new (diff[3]) into YAML objects. Assuming
+          # that doesn't error out, the return value is whether or not they're equal.
+          obj_old = ::YAML.load(diff[2])
+          obj_new = ::YAML.load(diff[3])
+          obj_old == obj_new
+        rescue # Rescue everything - if something failed, we aren't sure what's going on, so we'll return false.
+          false
+        end
+      end
+    end
+  end
+end
diff --git a/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-1.json b/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-1.json
new file mode 100644
index 00000000..850a1596
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-1.json
@@ -0,0 +1,22 @@
+{
+  "document_type": "Catalog",
+  "data": {
+    "tags": ["settings"],
+    "name": "my.rspec.node",
+    "version": "production",
+    "environment": "production",
+    "resources": [
+      {
+        "type": "File",
+        "title": "/tmp/foo.yaml",
+        "exported": false,
+        "parameters": {
+          "content": "foo:\n  bar: baz\n"
+        }
+      }
+    ]
+  },
+  "metadata": {
+    "api_version": 1
+  }
+}
diff --git a/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-2.json b/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-2.json
new file mode 100644
index 00000000..a144cec3
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/catalogs/ignore-equivalent-yaml-2.json
@@ -0,0 +1,22 @@
+{
+  "document_type": "Catalog",
+  "data": {
+    "tags": ["settings"],
+    "name": "my.rspec.node",
+    "version": "production",
+    "environment": "production",
+    "resources": [
+      {
+        "type": "File",
+        "title": "/tmp/foo.yaml",
+        "exported": false,
+        "parameters": {
+          "content": "---\n  foo:\n    bar: baz\n"
+        }
+      }
+    ]
+  },
+  "metadata": {
+    "api_version": 1
+  }
+}
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/hiera.yaml b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hiera.yaml
new file mode 100644
index 00000000..180b0a53
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hiera.yaml
@@ -0,0 +1,10 @@
+---
+:backends:
+  - yaml
+:yaml:
+  :datadir: /var/lib/puppet/environments/%{::environment}/hieradata
+:hierarchy:
+  - roles/%{::role}
+  - common
+:merge_behavior: deeper
+:logger: console
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/common.yaml b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/common.yaml
new file mode 100644
index 00000000..4fb48568
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/common.yaml
@@ -0,0 +1,2 @@
+---
+  bar::parameter: default
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/roles/bar.yaml b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/roles/bar.yaml
new file mode 100644
index 00000000..7cece0c6
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/hieradata/roles/bar.yaml
@@ -0,0 +1,2 @@
+---
+  bar::parameter: bar
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/manifests/site.pp b/spec/octocatalog-diff/fixtures/repos/yaml-diff/manifests/site.pp
new file mode 100644
index 00000000..355a89f0
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/manifests/site.pp
@@ -0,0 +1,3 @@
+node default {
+  include bar
+}
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.bar b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.bar
new file mode 100644
index 00000000..04b22b62
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.bar
@@ -0,0 +1,2 @@
+---
+  value: bar
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.default b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.default
new file mode 100644
index 00000000..ac8ea2bb
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/different-yaml.default
@@ -0,0 +1,2 @@
+---
+  value: default
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/identical-yaml b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/identical-yaml
new file mode 100644
index 00000000..e05acdb6
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/identical-yaml
@@ -0,0 +1,2 @@
+---
+  value: identical
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.bar b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.bar
new file mode 100644
index 00000000..ec2b2686
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.bar
@@ -0,0 +1 @@
+---{ "title": "This is not YAML", "value": "bar" }
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.default b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.default
new file mode 100644
index 00000000..c4a57a13
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/not-yaml.default
@@ -0,0 +1 @@
+---{ "title": "This is not YAML", "value": "default" }
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.bar b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.bar
new file mode 100644
index 00000000..535bab4b
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.bar
@@ -0,0 +1,7 @@
+---
+structure:
+  value: bar
+  array:
+  - foo
+  - bar
+  - baz
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.default b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.default
new file mode 100644
index 00000000..de5e4c1c
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/similar-yaml.default
@@ -0,0 +1,7 @@
+---
+  structure:
+    value: bar
+    array:
+      - foo
+      - bar
+      - baz
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.bar b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.bar
new file mode 100644
index 00000000..8cd580ac
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.bar
@@ -0,0 +1,3 @@
+--- !ruby/object:This::Does::Not::Exist
+  name: foo
+  value: bar
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.default b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.default
new file mode 100644
index 00000000..d4ca205d
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/files/unparseable-yaml.default
@@ -0,0 +1,3 @@
+--- !ruby/object:This::Does::Not::Exist
+  name: foo
+  value: default
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/manifests/init.pp b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/manifests/init.pp
new file mode 100644
index 00000000..7c8e3478
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/manifests/init.pp
@@ -0,0 +1,89 @@
+class bar ( $parameter ) {
+  file { '/tmp/template/different-yaml.yaml':
+    content => template('bar/different-yaml.erb')
+  }
+
+  file { "/tmp/template/different-yaml-${parameter}.yaml":
+    content => template('bar/different-yaml.erb')
+  }
+
+  file { '/tmp/template/identical-yaml.yaml':
+    content => template('bar/identical-yaml.erb')
+  }
+
+  file { '/tmp/template/not-yaml.yaml':
+    content => '{ "title": "This is not YAML" }'
+  }
+
+  file { '/tmp/template/not-yaml-2.yaml':
+    content => template('bar/not-yaml.erb')
+  }
+
+  file { "/tmp/template/not-yaml-${parameter}.yaml":
+    content => template('bar/not-yaml.erb')
+  }
+
+  file { '/tmp/template/similar-yaml.yaml':
+    content => template('bar/similar-yaml.erb')
+  }
+
+  file { '/tmp/template/similar-yaml.json':
+    content => template('bar/similar-yaml.erb')
+  }
+
+  file { '/tmp/template/unparseable-yaml.yaml':
+    content => template('bar/unparseable-yaml.erb'),
+  }
+
+  file { '/tmp/template/unparseable-yaml-2.yaml':
+    content => template('bar/unparseable-yaml-2.erb'),
+  }
+
+  file { "/tmp/template/unparseable-yaml-${parameter}.yaml":
+    content => template('bar/unparseable-yaml-2.erb'),
+  }
+
+  file { '/tmp/static/different-yaml.yaml':
+    source => "puppet:///modules/bar/different-yaml.${parameter}"
+  }
+
+  file { "/tmp/static/different-yaml-${parameter}.yaml":
+    source => "puppet:///modules/bar/different-yaml.${parameter}"
+  }
+
+  file { '/tmp/static/identical-yaml.yaml':
+    source => 'puppet:///modules/bar/identical-yaml',
+  }
+
+  file { '/tmp/static/not-yaml.yaml':
+    source => 'puppet:///modules/bar/not-yaml.default',
+  }
+
+  file { '/tmp/static/not-yaml-2.yaml':
+    source => "puppet:///modules/bar/not-yaml.${parameter}",
+  }
+
+  file { "/tmp/static/not-yaml-${parameter}.yaml":
+    source => "puppet:///modules/bar/not-yaml.${parameter}",
+  }
+
+  file { '/tmp/static/similar-yaml.yaml':
+    source => "puppet:///modules/bar/similar-yaml.${parameter}"
+  }
+
+  file { '/tmp/static/similar-yaml.json':
+    source => "puppet:///modules/bar/similar-yaml.${parameter}"
+  }
+
+  file { '/tmp/static/unparseable-yaml.yaml':
+    source => 'puppet:///modules/bar/unparseable-yaml.default',
+  }
+
+  file { '/tmp/static/unparseable-yaml-2.yaml':
+    source => "puppet:///modules/bar/unparseable-yaml.${parameter}",
+  }
+
+  file { "/tmp/static/unparseable-yaml-${parameter}.yaml":
+    source => "puppet:///modules/bar/unparseable-yaml.${parameter}",
+  }
+}
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/different-yaml.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/different-yaml.erb
new file mode 100644
index 00000000..db59d25e
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/different-yaml.erb
@@ -0,0 +1,2 @@
+---
+  value: <%= @parameter %>
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/identical-yaml.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/identical-yaml.erb
new file mode 100644
index 00000000..447f1ef3
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/identical-yaml.erb
@@ -0,0 +1,2 @@
+---
+  value: nachos
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/not-yaml.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/not-yaml.erb
new file mode 100644
index 00000000..8573a20d
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/not-yaml.erb
@@ -0,0 +1 @@
+---{ "title": "This is not YAML", "value": "<%= @parameter %>" }
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/similar-yaml.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/similar-yaml.erb
new file mode 100644
index 00000000..5e243614
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/similar-yaml.erb
@@ -0,0 +1,16 @@
+---
+<% if @parameter == 'default' %>
+  structure:
+    value: bar
+    array:
+      - foo
+      - bar
+      - baz
+<% else %>
+structure:
+  value: bar
+  array:
+  - foo
+  - bar
+  - baz
+<% end %>
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml-2.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml-2.erb
new file mode 100644
index 00000000..fbd72484
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml-2.erb
@@ -0,0 +1,3 @@
+--- !ruby/object:This::Does::Not::Exist
+  name: foo
+  value: baz
diff --git a/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml.erb b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml.erb
new file mode 100644
index 00000000..879c3a5a
--- /dev/null
+++ b/spec/octocatalog-diff/fixtures/repos/yaml-diff/modules/bar/templates/unparseable-yaml.erb
@@ -0,0 +1,3 @@
+--- !ruby/object:This::Does::Not::Exist
+  name: foo
+  value: <%= @parameter %>
diff --git a/spec/octocatalog-diff/integration/yaml_diff_spec.rb b/spec/octocatalog-diff/integration/yaml_diff_spec.rb
new file mode 100644
index 00000000..e32ccd72
--- /dev/null
+++ b/spec/octocatalog-diff/integration/yaml_diff_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require_relative 'integration_helper'
+require 'json'
+
+describe 'YAML file suppression for identical diffs' do
+  context 'with YAML diff suppression disabled' do
+    before(:all) do
+      argv = ['-n', 'rspec-node.github.net', '--to-fact-override', 'role=bar']
+      hash = { hiera_config: 'hiera.yaml', spec_fact_file: 'facts.yaml', spec_repo: 'yaml-diff' }
+      @result = OctocatalogDiff::Integration.integration(hash.merge(argv: argv))
+    end
+
+    it 'should compile without error' do
+      expect(@result.exitcode).to eq(2), OctocatalogDiff::Integration.format_exception(@result)
+      expect(@result.exception).to be_nil
+    end
+
+    it 'should contain the correct number of diffs' do
+      expect(@result.diffs.size).to eq(22)
+    end
+
+    it 'should contain the "similar JSON" static file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/static/similar-yaml.json\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+
+    it 'should contain the "similar YAML" static file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/static/similar-yaml.yaml\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+
+    it 'should contain the "similar JSON" template file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/template/similar-yaml.json\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+
+    it 'should contain the "similar YAML" template file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/template/similar-yaml.yaml\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+  end
+
+  context 'with YAML diff suppression enabled' do
+    before(:all) do
+      argv = ['-n', 'rspec-node.github.net', '--to-fact-override', 'role=bar', '--filters', 'YAML']
+      hash = { hiera_config: 'hiera.yaml', spec_fact_file: 'facts.yaml', spec_repo: 'yaml-diff' }
+      @result = OctocatalogDiff::Integration.integration(hash.merge(argv: argv))
+    end
+
+    it 'should compile without error' do
+      expect(@result.exitcode).to eq(2), OctocatalogDiff::Integration.format_exception(@result)
+      expect(@result.exception).to be_nil
+    end
+
+    it 'should contain the correct number of diffs' do
+      expect(@result.diffs.size).to eq(20)
+    end
+
+    it 'should contain the "similar JSON" static file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/static/similar-yaml.json\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+
+    it 'should not contain the "similar YAML" static file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/static/similar-yaml.yaml\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(false)
+    end
+
+    it 'should contain the "similar JSON" template file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/template/similar-yaml.json\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(true)
+    end
+
+    it 'should not contain the "similar YAML" template file as a diff' do
+      arr = @result.diffs
+      answer = ['~', "File\f/tmp/template/similar-yaml.yaml\fparameters\fcontent"]
+      expect(OctocatalogDiff::Spec.array_contains_partial_array?(arr, answer)).to eq(false)
+    end
+  end
+end
diff --git a/spec/octocatalog-diff/tests/catalog-diff/cli/options/filters_spec.rb b/spec/octocatalog-diff/tests/catalog-diff/cli/options/filters_spec.rb
new file mode 100644
index 00000000..c7c4ef10
--- /dev/null
+++ b/spec/octocatalog-diff/tests/catalog-diff/cli/options/filters_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require_relative '../options_helper'
+
+describe OctocatalogDiff::CatalogDiff::Cli::Options do
+  describe '#opt_ignore_equivalent_yaml_files' do
+    it 'should accept comma delimited parameters for --filters' do
+      result = run_optparse(['--filters', 'fizzbuzz,barbuzz'])
+      expect(result[:filters]).to eq(%w(fizzbuzz barbuzz))
+    end
+
+    it 'should accept multiple parameters for --filters' do
+      result = run_optparse(['--filters', 'fizzbuzz', '--filters', 'barbuzz'])
+      expect(result[:filters]).to eq(%w(fizzbuzz barbuzz))
+    end
+  end
+end
diff --git a/spec/octocatalog-diff/tests/catalog-diff/differ_spec.rb b/spec/octocatalog-diff/tests/catalog-diff/differ_spec.rb
index 0781f470..8d9594a5 100644
--- a/spec/octocatalog-diff/tests/catalog-diff/differ_spec.rb
+++ b/spec/octocatalog-diff/tests/catalog-diff/differ_spec.rb
@@ -532,7 +532,9 @@
       end
     end
   end
+end
 
+describe OctocatalogDiff::CatalogDiff::Differ do
   context 'ignoring only adds / removes / changes' do
     describe '#ignore' do
       before(:all) do
diff --git a/spec/octocatalog-diff/tests/catalog-diff/filter/yaml_spec.rb b/spec/octocatalog-diff/tests/catalog-diff/filter/yaml_spec.rb
new file mode 100644
index 00000000..53c5830b
--- /dev/null
+++ b/spec/octocatalog-diff/tests/catalog-diff/filter/yaml_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require_relative '../../spec_helper'
+require OctocatalogDiff::Spec.require_path('/catalog-diff/filter/yaml')
+
+describe OctocatalogDiff::CatalogDiff::Filter::YAML do
+  describe '#filtered?' do
+    let(:str1a) { "---\n  foo: bar" }
+    let(:str1b) { "---\nfoo: bar" }
+    let(:str2) { "---\n  foo: baz" }
+
+    it 'should not filter out an added resource' do
+      diff = ['+', "File\ffoobar.yaml", { 'parameters' => { 'content' => str1a } }]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a removed resource' do
+      diff = ['-', "File\ffoobar.yaml", { 'parameters' => { 'content' => str1a } }]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a non-file resource' do
+      diff = ['~', "Exec\ffoobar.yaml\fparameters\fcontent", str1a, str1b]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a file whose extension is not .yaml / .yml' do
+      diff = ['~', "File\ffoobar.json\fparameters\fcontent", str1a, str1b]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a change with no content change' do
+      diff = ['~', "File\ffoobar.json\fparameters\fowner", 'root', 'nobody']
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a change where YAML objects are dissimilar' do
+      diff = ['~', "File\ffoobar.yaml\fparameters\fcontent", str1a, str2]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a change where YAML is invalid' do
+      x_str = '---{ "blah": "foo" }'
+      diff = ['~', "File\ffoobar.yaml\fparameters\fcontent", x_str, x_str]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should not filter out a change where YAML is unparseable' do
+      x_str = "--- !ruby/object:This::Does::Not::Exist\n  foo: bar"
+      diff = ['~', "File\ffoobar.yaml\fparameters\fcontent", x_str, x_str]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(false)
+    end
+
+    it 'should filter out a whitespace-only change to a .yaml file' do
+      diff = ['~', "File\ffoobar.yaml\fparameters\fcontent", str1a, str1b]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(true)
+    end
+
+    it 'should filter out a whitespace-only change to a .yml file' do
+      diff = ['~', "File\ffoobar.yml\fparameters\fcontent", str1a, str1b]
+      result = described_class.filtered?(diff)
+      expect(result).to eq(true)
+    end
+  end
+end
diff --git a/spec/octocatalog-diff/tests/catalog-diff/filter_spec.rb b/spec/octocatalog-diff/tests/catalog-diff/filter_spec.rb
new file mode 100644
index 00000000..22ce2c08
--- /dev/null
+++ b/spec/octocatalog-diff/tests/catalog-diff/filter_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require_relative '../spec_helper'
+require OctocatalogDiff::Spec.require_path('/catalog-diff/filter')
+
+describe OctocatalogDiff::CatalogDiff::Filter do
+  before(:each) do
+    @class_1 = double
+    @class_2 = double
+    allow(Kernel).to receive(:const_get).with('OctocatalogDiff::CatalogDiff::Filter::Fake1').and_return(@class_1)
+    allow(Kernel).to receive(:const_get).with('OctocatalogDiff::CatalogDiff::Filter::Fake2').and_return(@class_2)
+  end
+
+  describe '#apply_filters' do
+    it 'should call self.filter() with appropriate options for each class' do
+      result = [false]
+      options = { 'Fake1' => { foo: 'bar' } }
+      classes = %w(Fake1 Fake2)
+      expect(@class_1).to receive(:'filtered?').with(false, foo: 'bar').and_return(false)
+      expect(@class_2).to receive(:'filtered?').with(false, {}).and_return(false)
+      expect { described_class.apply_filters(result, classes, options) }.not_to raise_error
+      expect(result).to eq([false])
+    end
+  end
+
+  describe '#filter' do
+    it 'should call .filtered?() in a class and remove matching items' do
+      result = [false, true]
+      expect(@class_1).to receive(:'filtered?').with(false, {}).and_return(false)
+      expect(@class_1).to receive(:'filtered?').with(true, {}).and_return(true)
+      expect { described_class.filter(result, 'Fake1') }.not_to raise_error
+      expect(result).to eq([false])
+    end
+  end
+
+  describe '#filtered?' do
+    it 'should raise error' do
+      expect { described_class.filtered?([]) }.to raise_error(RuntimeError, /No `filtered\?` method is implemented/)
+    end
+  end
+end