diff --git a/REFERENCE.md b/REFERENCE.md index 0d78da93d..0122b9020 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -4578,6 +4578,14 @@ file { '/tmp/my.yaml': } ``` +##### Sort keys for stable output + +```puppet +file { '/tmp/my.yaml': + ensure => file, + content => stdlib::to_yaml($myhash, {sort_keys => true}) +``` + #### `stdlib::to_yaml(Any $data, Optional[Hash] $options)` Convert a data structure and output it as YAML @@ -4604,6 +4612,14 @@ file { '/tmp/my.yaml': } ``` +###### Sort keys for stable output + +```puppet +file { '/tmp/my.yaml': + ensure => file, + content => stdlib::to_yaml($myhash, {sort_keys => true}) +``` + ##### `data` Data type: `Any` diff --git a/lib/puppet/functions/stdlib/to_yaml.rb b/lib/puppet/functions/stdlib/to_yaml.rb index ab162cd8b..92f330d2d 100644 --- a/lib/puppet/functions/stdlib/to_yaml.rb +++ b/lib/puppet/functions/stdlib/to_yaml.rb @@ -19,6 +19,10 @@ # ensure => file, # content => stdlib::to_yaml($myhash, {indentation => 4}) # } + # @example Sort keys for stable output + # file { '/tmp/my.yaml': + # ensure => file, + # content => stdlib::to_yaml($myhash, {sort_keys => true}) # # @return [String] The YAML document dispatch :to_yaml do @@ -28,7 +32,33 @@ def to_yaml(data, options = {}) call_function('stdlib::rewrap_sensitive_data', data) do |unwrapped_data| - unwrapped_data.to_yaml(options.transform_keys(&:to_sym)) + # 1) build a fresh, mutable hash of options + # transform_keys returns a new hash in Ruby 2.5+ + opts = options.transform_keys(&:to_sym) + + # 2) pluck out our custom flag (default false) + sort_keys = opts.delete(:sort_keys) || false + + # 3) sort if requested + unwrapped_data = deep_sort(unwrapped_data) if sort_keys + + # 4) dump with the remaining opts + unwrapped_data.to_yaml(opts) + end + end + + private + + def deep_sort(obj) + case obj + when Hash + # sort returns an Array of [k,v], then to_h makes a new Hash + obj.sort.to_h { |k, v| [k, deep_sort(v)] } + when Array + # preserve list order, but recurse into elements + obj.map { |item| deep_sort(item) } + else + obj end end end diff --git a/spec/functions/to_yaml_spec.rb b/spec/functions/to_yaml_spec.rb index edaceb877..0332a77c9 100644 --- a/spec/functions/to_yaml_spec.rb +++ b/spec/functions/to_yaml_spec.rb @@ -4,26 +4,119 @@ describe 'stdlib::to_yaml' do it { is_expected.not_to be_nil } + + # Basic scalars it { is_expected.to run.with_params('').and_return("--- ''\n") } - it { is_expected.to run.with_params(true).and_return(%r{--- true\n}) } - it { is_expected.to run.with_params('one').and_return(%r{--- one\n}) } + it { is_expected.to run.with_params(true).and_return("--- true\n") } + it { is_expected.to run.with_params(false).and_return("--- false\n") } + it { is_expected.to run.with_params(42).and_return("--- 42\n") } + it { is_expected.to run.with_params('one').and_return("--- one\n") } + + # Unicode + it { is_expected.to run.with_params('‰').and_return("--- \"‰\"\n") } + it { is_expected.to run.with_params('∇').and_return("--- \"∇\"\n") } + + # Arrays it { is_expected.to run.with_params([]).and_return("--- []\n") } it { is_expected.to run.with_params(['one']).and_return("---\n- one\n") } it { is_expected.to run.with_params(['one', 'two']).and_return("---\n- one\n- two\n") } + + # Hashes it { is_expected.to run.with_params({}).and_return("--- {}\n") } it { is_expected.to run.with_params('key' => 'value').and_return("---\nkey: value\n") } + # Nested structures it { - expect(subject).to run.with_params('one' => { 'oneA' => 'A', 'oneB' => { 'oneB1' => '1', 'oneB2' => '2' } }, 'two' => ['twoA', 'twoB']) - .and_return("---\none:\n oneA: A\n oneB:\n oneB1: '1'\n oneB2: '2'\ntwo:\n- twoA\n- twoB\n") + is_expected.to run.with_params( + 'one' => { 'oneA' => 'A', 'oneB' => { 'oneB1' => '1', 'oneB2' => '2' } }, + 'two' => ['twoA', 'twoB'], + ).and_return("---\none:\n oneA: A\n oneB:\n oneB1: '1'\n oneB2: '2'\ntwo:\n- twoA\n- twoB\n") } - it { is_expected.to run.with_params('‰').and_return("--- \"‰\"\n") } - it { is_expected.to run.with_params('∇').and_return("--- \"∇\"\n") } + # Options: indentation + it { + is_expected.to run.with_params( + { 'foo' => { 'bar' => true, 'baz' => false } }, + 'indentation' => 4, + ).and_return("---\nfoo:\n bar: true\n baz: false\n") + } + + # Frozen options hash must not raise + it { + is_expected.to run.with_params( + { 'foo' => { 'bar' => true } }, + { 'indentation' => 4, 'sort_keys' => true }.freeze, + ).and_return("---\nfoo:\n bar: true\n") + } + + # sort_keys: default behaviour preserves insertion order + it { + is_expected.to run.with_params( + 'z' => 1, 'a' => 2, 'm' => 3, + ).and_return("---\nz: 1\na: 2\nm: 3\n") + } + + # sort_keys => true: top-level keys sorted + it { + is_expected.to run.with_params( + { 'z' => 1, 'a' => 2, 'm' => 3 }, + 'sort_keys' => true, + ).and_return("---\na: 2\nm: 3\nz: 1\n") + } - it { is_expected.to run.with_params({ 'foo' => { 'bar' => true, 'baz' => false } }, 'indentation' => 4).and_return("---\nfoo:\n bar: true\n baz: false\n") } + # sort_keys => true: nested keys sorted + it { + is_expected.to run.with_params( + { 'parent' => { 'z' => 1, 'a' => 2 } }, + 'sort_keys' => true, + ).and_return("---\nparent:\n a: 2\n z: 1\n") + } + # sort_keys => true: deeply nested keys sorted + it { + is_expected.to run.with_params( + { 'z_parent' => { 'z_child' => 1, 'a_child' => 2 }, 'a_parent' => { 'x' => 5 } }, + 'sort_keys' => true, + ).and_return("---\na_parent:\n x: 5\nz_parent:\n a_child: 2\n z_child: 1\n") + } + + # sort_keys => true: array order preserved, hashes inside arrays sorted + it { + is_expected.to run.with_params( + { 'items' => [{ 'z' => 1, 'a' => 2 }, { 'x' => 3, 'b' => 4 }] }, + 'sort_keys' => true, + ).and_return("---\nitems:\n- a: 2\n z: 1\n- b: 4\n x: 3\n") + } + + # sort_keys => true: plain array element order preserved + it { + is_expected.to run.with_params( + { 'items' => [3, 1, 2] }, + 'sort_keys' => true, + ).and_return("---\nitems:\n- 3\n- 1\n- 2\n") + } + + # sort_keys combined with indentation + it { + is_expected.to run.with_params( + { 'z' => { 'b' => 1, 'a' => 2 }, 'a' => 3 }, + 'sort_keys' => true, 'indentation' => 4, + ).and_return("---\na: 3\nz:\n a: 2\n b: 1\n") + } + + # Sensitive data context 'with data containing sensitive' do - it { is_expected.to run.with_params('key' => sensitive('value')).and_return(sensitive("---\nkey: value\n")) } + it { + is_expected.to run.with_params( + 'key' => sensitive('value'), + ).and_return(sensitive("---\nkey: value\n")) + } + + it { + is_expected.to run.with_params( + { 'z' => sensitive('secret'), 'a' => 'public' }, + 'sort_keys' => true, + ).and_return(sensitive("---\na: public\nz: secret\n")) + } end end