From 8223d1356ba3904b59a13517fb161997498e1128 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Tue, 17 Jan 2023 19:30:09 +0100 Subject: [PATCH 01/13] Add `Loader::MultiDocYaml` to load multi-doc YAML The upcoming CI Catalog feature set includes an update to CI configuration files that requires the use of multi-document YAML. The existing `Loader::Yaml` class only accepts single document YAML, since `YAML.safe_load` does not work for multi-document files. This commit adds a new class to split a multi-document file into single documents, and then safely loads each document. --- lib/gitlab/config/loader/yaml.rb | 59 +++++-- spec/lib/gitlab/config/loader/yaml_spec.rb | 188 ++++++++++++++++++++- 2 files changed, 229 insertions(+), 18 deletions(-) diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index 7b87b5b8f97639..562a9f4c407cc5 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -5,50 +5,75 @@ module Config module Loader class Yaml DataTooLargeError = Class.new(Loader::FormatError) + TooManyDocumentsError = Class.new(Loader::FormatError) NotHashError = Class.new(Loader::FormatError) + MAX_DOCUMENTS = 2 + YAML_MULTI_DOC_DIVIDER = /^---$/.freeze + include Gitlab::Utils::StrongMemoize def initialize(config, additional_permitted_classes: []) - @config = YAML.safe_load(config, - permitted_classes: [Symbol, *additional_permitted_classes], - permitted_symbols: [], - aliases: true - ) + @safe_config = load_config(config, additional_permitted_classes) rescue Psych::Exception => e raise Loader::FormatError, e.message end def valid? - hash? && !too_big? + !too_many_documents? && hashes? && !too_big? end def load_raw! + raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? raise DataTooLargeError, 'The parsed YAML is too big' if too_big? - raise NotHashError, 'Invalid configuration format' unless hash? + raise NotHashError, 'Invalid configuration format' unless hashes? - @config + safe_config.one? ? safe_config.first : safe_config end def load! - @symbolized_config ||= load_raw!.deep_symbolize_keys + strong_memoize(:symbolized_config) do + if safe_config.one? + load_raw!.deep_symbolize_keys + else + load_raw!.map(&:deep_symbolize_keys) + end + end end private - def hash? - @config.is_a?(Hash) + attr_reader :safe_config + + def load_config(config, additional_permitted_classes) + config.split(YAML_MULTI_DOC_DIVIDER).map do |document| + YAML.safe_load(document, + permitted_classes: [Symbol, *additional_permitted_classes], + permitted_symbols: [], + aliases: true + ) + end + end + + def hashes? + !safe_config.empty? && safe_config.all?(Hash) + end + + def too_many_documents? + safe_config.count > MAX_DOCUMENTS end def too_big? - !deep_size.valid? + !deep_sizes.all?(&:valid?) end - def deep_size - strong_memoize(:deep_size) do - Gitlab::Utils::DeepSize.new(@config, - max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, - max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + def deep_sizes + strong_memoize(:deep_sizes) do + safe_config.map do |config| + Gitlab::Utils::DeepSize.new(config, + max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, + max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + end end end end diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index c7f84cd583c9d1..c7f884c366c692 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Config::Loader::Yaml do +RSpec.describe Gitlab::Config::Loader::Yaml, feature_category: :pipeline_authoring do let(:loader) { described_class.new(yml) } let(:yml) do @@ -155,6 +155,192 @@ end end + context 'when loading a multi-document YAML file' do + describe '#valid?' do + context 'when the content is an array of hashes' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns true' do + expect(loader).to be_valid + end + end + + context 'when a document is not a hash' do + let(:yml) do + <<~YAML + not_hash + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + + context 'when the content is too big' do + let(:yml) do + <<~YAML + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + --- + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + + context 'when there are too many documents' do + let(:yml) do + <<~YAML + a: b + --- + c: d + --- + e: f + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + end + + describe '#load_raw!' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns the loaded YAML' do + expect(loader.load_raw!).to eq([ + { 'spec' => { 'inputs' => { 'test_input' => nil } } }, + { 'test_job' => { 'script' => 'echo "$[[ inputs.test_input ]]"' } } + ]) + end + + context 'when the parsed YAML is too big' do + let(:yml) do + <<~YAML + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + --- + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + YAML + end + + it 'raises a DataTooLargeError' do + expect { loader.load_raw! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big') + end + end + + context 'when a document is not a hash' do + let(:yml) do + <<~YAML + not_a_hash + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'raises a NotHashError' do + expect { loader.load_raw! }.to raise_error(described_class::NotHashError, 'Invalid configuration format') + end + end + + context 'when there are too many documents' do + let(:yml) do + <<~YAML + a: b + --- + c: d + --- + e: f + YAML + end + + it 'raises a TooManyDocumentsError' do + expect { loader.load_raw! }.to raise_error( + described_class::TooManyDocumentsError, + 'The parsed YAML has too many documents' + ) + end + end + end + + describe '#load!' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns the loaded YAML with all keys as symbols' do + expect(loader.load!).to eq([ + { spec: { inputs: { test_input: nil } } }, + { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } + ]) + end + end + end + describe '#load_raw!' do it 'loads keys as strings' do expect(loader.load_raw!).to eq( -- GitLab From 1a13f0c12158be1f72d863f99e5ed0dbdce4f7cf Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Wed, 18 Jan 2023 15:21:42 +0100 Subject: [PATCH 02/13] Remove `nil` from config array Sometimes `nil` appears in the config arrays that pass through `Loader::Yaml` in the specs. I believe this is from having `---` at the top of the YAML content --- lib/gitlab/config/loader/yaml.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index 562a9f4c407cc5..aaee3e1f0ba6ea 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -14,7 +14,7 @@ class Yaml include Gitlab::Utils::StrongMemoize def initialize(config, additional_permitted_classes: []) - @safe_config = load_config(config, additional_permitted_classes) + @safe_config = load_config(config, additional_permitted_classes).compact rescue Psych::Exception => e raise Loader::FormatError, e.message end -- GitLab From f51cc18bb54e2d9e13f0b709433f0132f347e8ac Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Fri, 20 Jan 2023 18:05:17 +0100 Subject: [PATCH 03/13] Add a feature flag It's a big change to a heavily used class...best to be safe --- .../development/ci_multi_doc_yaml.yml | 8 +++ lib/gitlab/config/loader/yaml.rb | 53 ++++++++++++++---- spec/lib/gitlab/config/loader/yaml_spec.rb | 54 +++++++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 config/feature_flags/development/ci_multi_doc_yaml.yml diff --git a/config/feature_flags/development/ci_multi_doc_yaml.yml b/config/feature_flags/development/ci_multi_doc_yaml.yml new file mode 100644 index 00000000000000..4e6289abefa492 --- /dev/null +++ b/config/feature_flags/development/ci_multi_doc_yaml.yml @@ -0,0 +1,8 @@ +--- +name: ci_multi_doc_yaml +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109137 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/388836 +milestone: '15.9' +type: development +group: group::pipeline authoring +default_enabled: false diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index aaee3e1f0ba6ea..eafb78d6d214a6 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -20,20 +20,31 @@ def initialize(config, additional_permitted_classes: []) end def valid? - !too_many_documents? && hashes? && !too_big? + if Feature.enabled?(:ci_multi_doc_yaml) + !too_many_documents? && hashes? && !too_big? + else + hashes? && !too_big? + end end def load_raw! - raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? + if Feature.enabled?(:ci_multi_doc_yaml) && too_many_documents? + raise TooManyDocumentsError, 'The parsed YAML has too many documents' + end + raise DataTooLargeError, 'The parsed YAML is too big' if too_big? raise NotHashError, 'Invalid configuration format' unless hashes? - safe_config.one? ? safe_config.first : safe_config + if Feature.enabled?(:ci_multi_doc_yaml) && safe_config.one? + safe_config.first + else + safe_config + end end def load! strong_memoize(:symbolized_config) do - if safe_config.one? + if Feature.disabled?(:ci_multi_doc_yaml) || safe_config.one? load_raw!.deep_symbolize_keys else load_raw!.map(&:deep_symbolize_keys) @@ -46,8 +57,16 @@ def load! attr_reader :safe_config def load_config(config, additional_permitted_classes) - config.split(YAML_MULTI_DOC_DIVIDER).map do |document| - YAML.safe_load(document, + if Feature.enabled?(:ci_multi_doc_yaml) + config.split(YAML_MULTI_DOC_DIVIDER).map do |document| + YAML.safe_load(document, + permitted_classes: [Symbol, *additional_permitted_classes], + permitted_symbols: [], + aliases: true + ) + end + else + YAML.safe_load(config, permitted_classes: [Symbol, *additional_permitted_classes], permitted_symbols: [], aliases: true @@ -56,7 +75,11 @@ def load_config(config, additional_permitted_classes) end def hashes? - !safe_config.empty? && safe_config.all?(Hash) + if Feature.enabled?(:ci_multi_doc_yaml) + !safe_config.empty? && safe_config.all?(Hash) + else + safe_config.is_a?(Hash) + end end def too_many_documents? @@ -64,13 +87,23 @@ def too_many_documents? end def too_big? - !deep_sizes.all?(&:valid?) + if Feature.enabled?(:ci_multi_doc_yaml) + !deep_sizes.all?(&:valid?) + else + !deep_sizes.valid? + end end def deep_sizes strong_memoize(:deep_sizes) do - safe_config.map do |config| - Gitlab::Utils::DeepSize.new(config, + if Feature.enabled?(:ci_multi_doc_yaml) + safe_config.map do |config| + Gitlab::Utils::DeepSize.new(config, + max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, + max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + end + else + Gitlab::Utils::DeepSize.new(safe_config, max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) end diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index c7f884c366c692..2bb5fdc849f367 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -234,6 +234,24 @@ expect(loader).not_to be_valid end end + + context 'when `ci_multi_doc_yaml` is disabled' do + let(:yml) do + <<~YAML + a: b + --- + c: d + YAML + end + + before do + stub_feature_flags(ci_multi_doc_yaml: false) + end + + it 'only validates the first document in a multi-document file' do + expect(loader).to be_valid + end + end end describe '#load_raw!' do @@ -318,6 +336,24 @@ ) end end + + context 'when `ci_multi_doc_yaml` is disabled' do + let(:yml) do + <<~YAML + a: b + --- + c: d + YAML + end + + before do + stub_feature_flags(ci_multi_doc_yaml: false) + end + + it 'only parses the first document in a multi-document file' do + expect(loader.load_raw!).to eq({ 'a' => 'b' }) + end + end end describe '#load!' do @@ -338,6 +374,24 @@ { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } ]) end + + context 'when `ci_multi_doc_yaml` is disabled' do + let(:yml) do + <<~YAML + a: b + --- + c: d + YAML + end + + before do + stub_feature_flags(ci_multi_doc_yaml: false) + end + + it 'only parses the first document in a multi-document file' do + expect(loader.load!).to eq({ a: 'b' }) + end + end end end -- GitLab From a56de2371304d176d9338d5853bb41b6f771d636 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Fri, 27 Jan 2023 16:20:45 +0100 Subject: [PATCH 04/13] Extract multi-doc parsing into new class This should help avoid the risk that we unintentionally change functionality for any existing consumers of `Loader::Yaml` --- lib/gitlab/config/loader/multi_doc_yaml.rb | 66 +++++ lib/gitlab/config/loader/yaml.rb | 92 ++----- .../config/loader/multi_doc_yaml_spec.rb | 191 ++++++++++++++ spec/lib/gitlab/config/loader/yaml_spec.rb | 240 ------------------ 4 files changed, 279 insertions(+), 310 deletions(-) create mode 100644 lib/gitlab/config/loader/multi_doc_yaml.rb create mode 100644 spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb new file mode 100644 index 00000000000000..6dce5ac5fd2aed --- /dev/null +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module Config + module Loader + class MultiDocYaml < Yaml + TooManyDocumentsError = Class.new(Loader::FormatError) + + MAX_DOCUMENTS = 2 + YAML_MULTI_DOC_DIVIDER = /^---$/.freeze + + def valid? + !too_many_documents? && super + end + + def load_raw! + raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? + + super + end + + private + + attr_reader :safe_config + + def symbolized_config + load_raw!.map(&:deep_symbolize_keys) + end + strong_memoize_attr :symbolized_config + + def load_config(config, additional_permitted_classes) + config.split(YAML_MULTI_DOC_DIVIDER).filter_map do |document| + safe_document = YAML.safe_load(document, + permitted_classes: [Symbol, *additional_permitted_classes], + permitted_symbols: [], + aliases: true + ) + + safe_document unless safe_document.nil? + end + end + + def hash? + !safe_config.empty? && safe_config.all?(Hash) + end + + def too_many_documents? + safe_config.count > MAX_DOCUMENTS + end + + def too_big? + !deep_size.all?(&:valid?) + end + + def deep_size + safe_config.map do |config| + Gitlab::Utils::DeepSize.new(config, + max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, + max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + end + end + strong_memoize_attr :deep_size + end + end + end +end diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index eafb78d6d214a6..ef7ae2391c8530 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -5,110 +5,62 @@ module Config module Loader class Yaml DataTooLargeError = Class.new(Loader::FormatError) - TooManyDocumentsError = Class.new(Loader::FormatError) NotHashError = Class.new(Loader::FormatError) - MAX_DOCUMENTS = 2 - YAML_MULTI_DOC_DIVIDER = /^---$/.freeze - include Gitlab::Utils::StrongMemoize def initialize(config, additional_permitted_classes: []) - @safe_config = load_config(config, additional_permitted_classes).compact + @safe_config = load_config(config, additional_permitted_classes) rescue Psych::Exception => e raise Loader::FormatError, e.message end def valid? - if Feature.enabled?(:ci_multi_doc_yaml) - !too_many_documents? && hashes? && !too_big? - else - hashes? && !too_big? - end + hash? && !too_big? end def load_raw! - if Feature.enabled?(:ci_multi_doc_yaml) && too_many_documents? - raise TooManyDocumentsError, 'The parsed YAML has too many documents' - end - raise DataTooLargeError, 'The parsed YAML is too big' if too_big? - raise NotHashError, 'Invalid configuration format' unless hashes? + raise NotHashError, 'Invalid configuration format' unless hash? - if Feature.enabled?(:ci_multi_doc_yaml) && safe_config.one? - safe_config.first - else - safe_config - end + safe_config end def load! - strong_memoize(:symbolized_config) do - if Feature.disabled?(:ci_multi_doc_yaml) || safe_config.one? - load_raw!.deep_symbolize_keys - else - load_raw!.map(&:deep_symbolize_keys) - end - end + symbolized_config end private attr_reader :safe_config - def load_config(config, additional_permitted_classes) - if Feature.enabled?(:ci_multi_doc_yaml) - config.split(YAML_MULTI_DOC_DIVIDER).map do |document| - YAML.safe_load(document, - permitted_classes: [Symbol, *additional_permitted_classes], - permitted_symbols: [], - aliases: true - ) - end - else - YAML.safe_load(config, - permitted_classes: [Symbol, *additional_permitted_classes], - permitted_symbols: [], - aliases: true - ) - end + def symbolized_config + load_raw!.deep_symbolize_keys end + strong_memoize_attr :symbolized_config - def hashes? - if Feature.enabled?(:ci_multi_doc_yaml) - !safe_config.empty? && safe_config.all?(Hash) - else - safe_config.is_a?(Hash) - end + def load_config(config, additional_permitted_classes) + YAML.safe_load(config, + permitted_classes: [Symbol, *additional_permitted_classes], + permitted_symbols: [], + aliases: true + ) end - def too_many_documents? - safe_config.count > MAX_DOCUMENTS + def hash? + safe_config.is_a?(Hash) end def too_big? - if Feature.enabled?(:ci_multi_doc_yaml) - !deep_sizes.all?(&:valid?) - else - !deep_sizes.valid? - end + !deep_size.valid? end - def deep_sizes - strong_memoize(:deep_sizes) do - if Feature.enabled?(:ci_multi_doc_yaml) - safe_config.map do |config| - Gitlab::Utils::DeepSize.new(config, - max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, - max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) - end - else - Gitlab::Utils::DeepSize.new(safe_config, - max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, - max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) - end - end + def deep_size + Gitlab::Utils::DeepSize.new(safe_config, + max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, + max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) end + strong_memoize_attr :deep_size end end end diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb new file mode 100644 index 00000000000000..9d570a272739fa --- /dev/null +++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline_authoring do + let(:loader) { described_class.new(yml) } + + describe '#valid?' do + context 'when the content is an array of hashes' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns true' do + expect(loader).to be_valid + end + end + + context 'when a document is not a hash' do + let(:yml) do + <<~YAML + not_hash + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + + context 'when the content is too big' do + let(:yml) do + <<~YAML + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + --- + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + + context 'when there are too many documents' do + let(:yml) do + <<~YAML + a: b + --- + c: d + --- + e: f + YAML + end + + it 'returns false' do + expect(loader).not_to be_valid + end + end + end + + describe '#load_raw!' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns the loaded YAML' do + expect(loader.load_raw!).to eq([ + { 'spec' => { 'inputs' => { 'test_input' => nil } } }, + { 'test_job' => { 'script' => 'echo "$[[ inputs.test_input ]]"' } } + ]) + end + + context 'when the parsed YAML is too big' do + let(:yml) do + <<~YAML + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + --- + a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] + g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] + h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] + i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] + YAML + end + + it 'raises a DataTooLargeError' do + expect { loader.load_raw! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big') + end + end + + context 'when a document is not a hash' do + let(:yml) do + <<~YAML + not_a_hash + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'raises a NotHashError' do + expect { loader.load_raw! }.to raise_error(described_class::NotHashError, 'Invalid configuration format') + end + end + + context 'when there are too many documents' do + let(:yml) do + <<~YAML + a: b + --- + c: d + --- + e: f + YAML + end + + it 'raises a TooManyDocumentsError' do + expect { loader.load_raw! }.to raise_error( + described_class::TooManyDocumentsError, + 'The parsed YAML has too many documents' + ) + end + end + end + + describe '#load!' do + let(:yml) do + <<~YAML + spec: + inputs: + test_input: + --- + test_job: + script: echo "$[[ inputs.test_input ]]" + YAML + end + + it 'returns the loaded YAML with all keys as symbols' do + expect(loader.load!).to eq([ + { spec: { inputs: { test_input: nil } } }, + { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } + ]) + end + end +end diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index 2bb5fdc849f367..346424d1681429 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -155,246 +155,6 @@ end end - context 'when loading a multi-document YAML file' do - describe '#valid?' do - context 'when the content is an array of hashes' do - let(:yml) do - <<~YAML - spec: - inputs: - test_input: - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns true' do - expect(loader).to be_valid - end - end - - context 'when a document is not a hash' do - let(:yml) do - <<~YAML - not_hash - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - - context 'when the content is too big' do - let(:yml) do - <<~YAML - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - --- - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - - context 'when there are too many documents' do - let(:yml) do - <<~YAML - a: b - --- - c: d - --- - e: f - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - - context 'when `ci_multi_doc_yaml` is disabled' do - let(:yml) do - <<~YAML - a: b - --- - c: d - YAML - end - - before do - stub_feature_flags(ci_multi_doc_yaml: false) - end - - it 'only validates the first document in a multi-document file' do - expect(loader).to be_valid - end - end - end - - describe '#load_raw!' do - let(:yml) do - <<~YAML - spec: - inputs: - test_input: - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns the loaded YAML' do - expect(loader.load_raw!).to eq([ - { 'spec' => { 'inputs' => { 'test_input' => nil } } }, - { 'test_job' => { 'script' => 'echo "$[[ inputs.test_input ]]"' } } - ]) - end - - context 'when the parsed YAML is too big' do - let(:yml) do - <<~YAML - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - --- - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - YAML - end - - it 'raises a DataTooLargeError' do - expect { loader.load_raw! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big') - end - end - - context 'when a document is not a hash' do - let(:yml) do - <<~YAML - not_a_hash - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'raises a NotHashError' do - expect { loader.load_raw! }.to raise_error(described_class::NotHashError, 'Invalid configuration format') - end - end - - context 'when there are too many documents' do - let(:yml) do - <<~YAML - a: b - --- - c: d - --- - e: f - YAML - end - - it 'raises a TooManyDocumentsError' do - expect { loader.load_raw! }.to raise_error( - described_class::TooManyDocumentsError, - 'The parsed YAML has too many documents' - ) - end - end - - context 'when `ci_multi_doc_yaml` is disabled' do - let(:yml) do - <<~YAML - a: b - --- - c: d - YAML - end - - before do - stub_feature_flags(ci_multi_doc_yaml: false) - end - - it 'only parses the first document in a multi-document file' do - expect(loader.load_raw!).to eq({ 'a' => 'b' }) - end - end - end - - describe '#load!' do - let(:yml) do - <<~YAML - spec: - inputs: - test_input: - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns the loaded YAML with all keys as symbols' do - expect(loader.load!).to eq([ - { spec: { inputs: { test_input: nil } } }, - { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } - ]) - end - - context 'when `ci_multi_doc_yaml` is disabled' do - let(:yml) do - <<~YAML - a: b - --- - c: d - YAML - end - - before do - stub_feature_flags(ci_multi_doc_yaml: false) - end - - it 'only parses the first document in a multi-document file' do - expect(loader.load!).to eq({ a: 'b' }) - end - end - end - end - describe '#load_raw!' do it 'loads keys as strings' do expect(loader.load_raw!).to eq( -- GitLab From 37553b1ff38a03825af02eb185df13c32025d205 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Fri, 27 Jan 2023 16:42:30 +0100 Subject: [PATCH 05/13] Use `MultiDocYaml` in `Ci::Config::Yaml` This is the only place we will need `MultiDocYaml`, at least at first, since it is only used to parse content from `include`s --- lib/gitlab/ci/config/yaml.rb | 9 ++- spec/lib/gitlab/ci/config/yaml_spec.rb | 105 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 spec/lib/gitlab/ci/config/yaml_spec.rb diff --git a/lib/gitlab/ci/config/yaml.rb b/lib/gitlab/ci/config/yaml.rb index de833619c8df24..9586093b0d724d 100644 --- a/lib/gitlab/ci/config/yaml.rb +++ b/lib/gitlab/ci/config/yaml.rb @@ -10,7 +10,14 @@ class << self def load!(content) ensure_custom_tags - Gitlab::Config::Loader::Yaml.new(content, additional_permitted_classes: AVAILABLE_TAGS).load! + if ::Feature.enabled?(:ci_multi_doc_yaml) + Gitlab::Config::Loader::MultiDocYaml.new( + content, + additional_permitted_classes: AVAILABLE_TAGS + ).load!.first + else + Gitlab::Config::Loader::Yaml.new(content, additional_permitted_classes: AVAILABLE_TAGS).load! + end end private diff --git a/spec/lib/gitlab/ci/config/yaml_spec.rb b/spec/lib/gitlab/ci/config/yaml_spec.rb new file mode 100644 index 00000000000000..4b34553f55e586 --- /dev/null +++ b/spec/lib/gitlab/ci/config/yaml_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Yaml, feature_category: :pipeline_authoring do + describe '.load!' do + it 'loads a single-doc YAML file' do + yaml = <<~YAML + image: 'image:1.0' + texts: + nested_key: 'value1' + more_text: + more_nested_key: 'value2' + YAML + + config = described_class.load!(yaml) + + expect(config).to eq({ + image: 'image:1.0', + texts: { + nested_key: 'value1', + more_text: { + more_nested_key: 'value2' + } + } + }) + end + + it 'loads the first document from a multi-doc YAML file' do + yaml = <<~YAML + spec: + inputs: + test_input: + --- + image: 'image:1.0' + texts: + nested_key: 'value1' + more_text: + more_nested_key: 'value2' + YAML + + config = described_class.load!(yaml) + + expect(config).to eq({ + spec: { + inputs: { + test_input: nil + } + } + }) + end + + context 'when ci_multi_doc_yaml is disabled' do + before do + stub_feature_flags(ci_multi_doc_yaml: false) + end + + it 'loads a single-doc YAML file' do + yaml = <<~YAML + image: 'image:1.0' + texts: + nested_key: 'value1' + more_text: + more_nested_key: 'value2' + YAML + + config = described_class.load!(yaml) + + expect(config).to eq({ + image: 'image:1.0', + texts: { + nested_key: 'value1', + more_text: { + more_nested_key: 'value2' + } + } + }) + end + + it 'loads the first document from a multi-doc YAML file' do + yaml = <<~YAML + spec: + inputs: + test_input: + --- + image: 'image:1.0' + texts: + nested_key: 'value1' + more_text: + more_nested_key: 'value2' + YAML + + config = described_class.load!(yaml) + + expect(config).to eq({ + spec: { + inputs: { + test_input: nil + } + } + }) + end + end + end +end -- GitLab From 8cf9bec6550fafd6101b54f4a7005e2c4fd68989 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Fri, 27 Jan 2023 16:49:17 +0100 Subject: [PATCH 06/13] Move `rescue` to instance method Now it is in the same location as the logic that might throw an exception --- lib/gitlab/config/loader/yaml.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index ef7ae2391c8530..f487d886fce48e 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -11,8 +11,6 @@ class Yaml def initialize(config, additional_permitted_classes: []) @safe_config = load_config(config, additional_permitted_classes) - rescue Psych::Exception => e - raise Loader::FormatError, e.message end def valid? @@ -45,6 +43,8 @@ def load_config(config, additional_permitted_classes) permitted_symbols: [], aliases: true ) + rescue Psych::Exception => e + raise Loader::FormatError, e.message end def hash? -- GitLab From 9e00f34d4734fad82141b10334dd61ab90154f8b Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Tue, 31 Jan 2023 15:23:13 +0100 Subject: [PATCH 07/13] Remove parent from `MultiDocYaml` Most of the content needs to be re-written anyway --- lib/gitlab/config/loader/multi_doc_yaml.rb | 22 ++++++++++-- lib/gitlab/config/loader/yaml.rb | 40 ++++++++-------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index 6dce5ac5fd2aed..77498e26f6bbc3 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -3,20 +3,34 @@ module Gitlab module Config module Loader - class MultiDocYaml < Yaml + class MultiDocYaml TooManyDocumentsError = Class.new(Loader::FormatError) + DataTooLargeError = Class.new(Loader::FormatError) + NotHashError = Class.new(Loader::FormatError) + + include Gitlab::Utils::StrongMemoize MAX_DOCUMENTS = 2 YAML_MULTI_DOC_DIVIDER = /^---$/.freeze + def initialize(config, additional_permitted_classes: []) + @safe_config = load_config(config, additional_permitted_classes) + end + def valid? - !too_many_documents? && super + !too_many_documents? && hash? && !too_big? end def load_raw! raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? + raise DataTooLargeError, 'The parsed YAML is too big' if too_big? + raise NotHashError, 'Invalid configuration format' unless hash? + + safe_config + end - super + def load! + symbolized_config end private @@ -38,6 +52,8 @@ def load_config(config, additional_permitted_classes) safe_document unless safe_document.nil? end + rescue Psych::Exception => e + raise Loader::FormatError, e.message end def hash? diff --git a/lib/gitlab/config/loader/yaml.rb b/lib/gitlab/config/loader/yaml.rb index f487d886fce48e..7b87b5b8f97639 100644 --- a/lib/gitlab/config/loader/yaml.rb +++ b/lib/gitlab/config/loader/yaml.rb @@ -10,7 +10,13 @@ class Yaml include Gitlab::Utils::StrongMemoize def initialize(config, additional_permitted_classes: []) - @safe_config = load_config(config, additional_permitted_classes) + @config = YAML.safe_load(config, + permitted_classes: [Symbol, *additional_permitted_classes], + permitted_symbols: [], + aliases: true + ) + rescue Psych::Exception => e + raise Loader::FormatError, e.message end def valid? @@ -21,34 +27,17 @@ def load_raw! raise DataTooLargeError, 'The parsed YAML is too big' if too_big? raise NotHashError, 'Invalid configuration format' unless hash? - safe_config + @config end def load! - symbolized_config + @symbolized_config ||= load_raw!.deep_symbolize_keys end private - attr_reader :safe_config - - def symbolized_config - load_raw!.deep_symbolize_keys - end - strong_memoize_attr :symbolized_config - - def load_config(config, additional_permitted_classes) - YAML.safe_load(config, - permitted_classes: [Symbol, *additional_permitted_classes], - permitted_symbols: [], - aliases: true - ) - rescue Psych::Exception => e - raise Loader::FormatError, e.message - end - def hash? - safe_config.is_a?(Hash) + @config.is_a?(Hash) end def too_big? @@ -56,11 +45,12 @@ def too_big? end def deep_size - Gitlab::Utils::DeepSize.new(safe_config, - max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, - max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + strong_memoize(:deep_size) do + Gitlab::Utils::DeepSize.new(@config, + max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, + max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) + end end - strong_memoize_attr :deep_size end end end -- GitLab From 89048ff693937c228aa9994abd7cf6780f16f614 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Tue, 31 Jan 2023 15:30:39 +0100 Subject: [PATCH 08/13] Refactor multi-doc loader * Pluralizes method names since more than one document is loaded * Removes `load_raw!` since it isn't needed --- lib/gitlab/config/loader/multi_doc_yaml.rb | 26 +++++--------- .../config/loader/multi_doc_yaml_spec.rb | 36 +++++-------------- 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index 77498e26f6bbc3..b8300d78f0cbce 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -18,30 +18,22 @@ def initialize(config, additional_permitted_classes: []) end def valid? - !too_many_documents? && hash? && !too_big? + !too_many_documents? && all_hashes? && !too_big? end - def load_raw! + def load! raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? raise DataTooLargeError, 'The parsed YAML is too big' if too_big? - raise NotHashError, 'Invalid configuration format' unless hash? - - safe_config - end + raise NotHashError, 'Invalid configuration format' unless all_hashes? - def load! - symbolized_config + safe_config.map(&:deep_symbolize_keys) end + strong_memoize_attr :load! private attr_reader :safe_config - def symbolized_config - load_raw!.map(&:deep_symbolize_keys) - end - strong_memoize_attr :symbolized_config - def load_config(config, additional_permitted_classes) config.split(YAML_MULTI_DOC_DIVIDER).filter_map do |document| safe_document = YAML.safe_load(document, @@ -56,7 +48,7 @@ def load_config(config, additional_permitted_classes) raise Loader::FormatError, e.message end - def hash? + def all_hashes? !safe_config.empty? && safe_config.all?(Hash) end @@ -65,17 +57,17 @@ def too_many_documents? end def too_big? - !deep_size.all?(&:valid?) + !deep_sizes.all?(&:valid?) end - def deep_size + def deep_sizes safe_config.map do |config| Gitlab::Utils::DeepSize.new(config, max_size: Gitlab::CurrentSettings.current_application_settings.max_yaml_size_bytes, max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) end end - strong_memoize_attr :deep_size + strong_memoize_attr :deep_sizes end end end diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb index 9d570a272739fa..627d26f618b72c 100644 --- a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb @@ -85,7 +85,7 @@ end end - describe '#load_raw!' do + describe '#load!' do let(:yml) do <<~YAML spec: @@ -97,10 +97,10 @@ YAML end - it 'returns the loaded YAML' do - expect(loader.load_raw!).to eq([ - { 'spec' => { 'inputs' => { 'test_input' => nil } } }, - { 'test_job' => { 'script' => 'echo "$[[ inputs.test_input ]]"' } } + it 'returns the loaded YAML with all keys as symbols' do + expect(loader.load!).to eq([ + { spec: { inputs: { test_input: nil } } }, + { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } ]) end @@ -130,7 +130,7 @@ end it 'raises a DataTooLargeError' do - expect { loader.load_raw! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big') + expect { loader.load! }.to raise_error(described_class::DataTooLargeError, 'The parsed YAML is too big') end end @@ -145,7 +145,7 @@ end it 'raises a NotHashError' do - expect { loader.load_raw! }.to raise_error(described_class::NotHashError, 'Invalid configuration format') + expect { loader.load! }.to raise_error(described_class::NotHashError, 'Invalid configuration format') end end @@ -161,31 +161,11 @@ end it 'raises a TooManyDocumentsError' do - expect { loader.load_raw! }.to raise_error( + expect { loader.load! }.to raise_error( described_class::TooManyDocumentsError, 'The parsed YAML has too many documents' ) end end end - - describe '#load!' do - let(:yml) do - <<~YAML - spec: - inputs: - test_input: - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns the loaded YAML with all keys as symbols' do - expect(loader.load!).to eq([ - { spec: { inputs: { test_input: nil } } }, - { test_job: { script: 'echo "$[[ inputs.test_input ]]"' } } - ]) - end - end end -- GitLab From dc33d24c4eed827106b8dd47c8fe3c437ff50f5e Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Tue, 31 Jan 2023 16:47:21 +0100 Subject: [PATCH 09/13] Add new exception to `Ci::Config` `Ci::Config` should take handle `DataTooLargeError`s from either config loader --- lib/gitlab/ci/config.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 142f0b8dfd8c3e..585e671ce426be 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -117,7 +117,8 @@ def metadata def expand_config(config) build_config(config) - rescue Gitlab::Config::Loader::Yaml::DataTooLargeError => e + rescue Gitlab::Config::Loader::Yaml::DataTooLargeError, + Gitlab::Config::Loader::MultiDocYaml::DataTooLargeError => e track_and_raise_for_dev_exception(e) raise Config::ConfigError, e.message -- GitLab From 96cc1c64c60cdd1bbc329e6851ece556d8ba1c98 Mon Sep 17 00:00:00 2001 From: Furkan Ayhan Date: Wed, 1 Feb 2023 14:20:07 +0000 Subject: [PATCH 10/13] Remove redundant clause --- lib/gitlab/config/loader/multi_doc_yaml.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index b8300d78f0cbce..37c62fb0eba9ac 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -36,13 +36,11 @@ def load! def load_config(config, additional_permitted_classes) config.split(YAML_MULTI_DOC_DIVIDER).filter_map do |document| - safe_document = YAML.safe_load(document, + YAML.safe_load(document, permitted_classes: [Symbol, *additional_permitted_classes], permitted_symbols: [], aliases: true ) - - safe_document unless safe_document.nil? end rescue Psych::Exception => e raise Loader::FormatError, e.message -- GitLab From 7de1aa622667f46318b2b824d8a5d34338587390 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Wed, 1 Feb 2023 15:35:47 +0100 Subject: [PATCH 11/13] Remove unnecessary methods Since `MultiDocYaml` is no longer directly related to `Yaml`, we only need to keep the methods that are used by `Ci::Config` --- lib/gitlab/config/loader/multi_doc_yaml.rb | 12 +-- .../config/loader/multi_doc_yaml_spec.rb | 80 ------------------- 2 files changed, 2 insertions(+), 90 deletions(-) diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index 37c62fb0eba9ac..3332e66dff90be 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -8,19 +8,13 @@ class MultiDocYaml DataTooLargeError = Class.new(Loader::FormatError) NotHashError = Class.new(Loader::FormatError) - include Gitlab::Utils::StrongMemoize - MAX_DOCUMENTS = 2 - YAML_MULTI_DOC_DIVIDER = /^---$/.freeze + MULTI_DOC_DIVIDER = /^---$/.freeze def initialize(config, additional_permitted_classes: []) @safe_config = load_config(config, additional_permitted_classes) end - def valid? - !too_many_documents? && all_hashes? && !too_big? - end - def load! raise TooManyDocumentsError, 'The parsed YAML has too many documents' if too_many_documents? raise DataTooLargeError, 'The parsed YAML is too big' if too_big? @@ -28,14 +22,13 @@ def load! safe_config.map(&:deep_symbolize_keys) end - strong_memoize_attr :load! private attr_reader :safe_config def load_config(config, additional_permitted_classes) - config.split(YAML_MULTI_DOC_DIVIDER).filter_map do |document| + config.split(MULTI_DOC_DIVIDER).filter_map do |document| YAML.safe_load(document, permitted_classes: [Symbol, *additional_permitted_classes], permitted_symbols: [], @@ -65,7 +58,6 @@ def deep_sizes max_depth: Gitlab::CurrentSettings.current_application_settings.max_yaml_depth) end end - strong_memoize_attr :deep_sizes end end end diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb index 627d26f618b72c..a68a85e0b61ba4 100644 --- a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb @@ -5,86 +5,6 @@ RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline_authoring do let(:loader) { described_class.new(yml) } - describe '#valid?' do - context 'when the content is an array of hashes' do - let(:yml) do - <<~YAML - spec: - inputs: - test_input: - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns true' do - expect(loader).to be_valid - end - end - - context 'when a document is not a hash' do - let(:yml) do - <<~YAML - not_hash - --- - test_job: - script: echo "$[[ inputs.test_input ]]" - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - - context 'when the content is too big' do - let(:yml) do - <<~YAML - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - --- - a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] - b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] - c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] - d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] - e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] - f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] - g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] - h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] - i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - - context 'when there are too many documents' do - let(:yml) do - <<~YAML - a: b - --- - c: d - --- - e: f - YAML - end - - it 'returns false' do - expect(loader).not_to be_valid - end - end - end - describe '#load!' do let(:yml) do <<~YAML -- GitLab From 5747fc7c5958dee4966bb55da77b532f26bd104b Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Wed, 1 Feb 2023 15:40:41 +0100 Subject: [PATCH 12/13] Pass in max documents as an argument This makes `MultiDocYaml` more reusable --- lib/gitlab/ci/config/yaml.rb | 2 ++ lib/gitlab/config/loader/multi_doc_yaml.rb | 8 ++++---- spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/gitlab/ci/config/yaml.rb b/lib/gitlab/ci/config/yaml.rb index 9586093b0d724d..94ef0afe7f9fa4 100644 --- a/lib/gitlab/ci/config/yaml.rb +++ b/lib/gitlab/ci/config/yaml.rb @@ -5,6 +5,7 @@ module Ci class Config module Yaml AVAILABLE_TAGS = [Config::Yaml::Tags::Reference].freeze + MAX_DOCUMENTS = 2 class << self def load!(content) @@ -13,6 +14,7 @@ def load!(content) if ::Feature.enabled?(:ci_multi_doc_yaml) Gitlab::Config::Loader::MultiDocYaml.new( content, + max_documents: MAX_DOCUMENTS, additional_permitted_classes: AVAILABLE_TAGS ).load!.first else diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index 3332e66dff90be..9003221771b63b 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -8,10 +8,10 @@ class MultiDocYaml DataTooLargeError = Class.new(Loader::FormatError) NotHashError = Class.new(Loader::FormatError) - MAX_DOCUMENTS = 2 MULTI_DOC_DIVIDER = /^---$/.freeze - def initialize(config, additional_permitted_classes: []) + def initialize(config, max_documents:, additional_permitted_classes: []) + @max_documents = max_documents @safe_config = load_config(config, additional_permitted_classes) end @@ -25,7 +25,7 @@ def load! private - attr_reader :safe_config + attr_reader :safe_config, :max_documents def load_config(config, additional_permitted_classes) config.split(MULTI_DOC_DIVIDER).filter_map do |document| @@ -44,7 +44,7 @@ def all_hashes? end def too_many_documents? - safe_config.count > MAX_DOCUMENTS + safe_config.count > max_documents end def too_big? diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb index a68a85e0b61ba4..c8e52d86890140 100644 --- a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Config::Loader::MultiDocYaml, feature_category: :pipeline_authoring do - let(:loader) { described_class.new(yml) } + let(:loader) { described_class.new(yml, max_documents: 2) } describe '#load!' do let(:yml) do -- GitLab From ebf31ea8ed35335e2c4f24ac9911a938bd782365 Mon Sep 17 00:00:00 2001 From: Avielle Wolfe Date: Wed, 1 Feb 2023 15:46:48 +0100 Subject: [PATCH 13/13] Allow `MultiDocYaml` to return an empty array --- lib/gitlab/config/loader/multi_doc_yaml.rb | 2 +- spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/config/loader/multi_doc_yaml.rb b/lib/gitlab/config/loader/multi_doc_yaml.rb index 9003221771b63b..346adc79896c5b 100644 --- a/lib/gitlab/config/loader/multi_doc_yaml.rb +++ b/lib/gitlab/config/loader/multi_doc_yaml.rb @@ -40,7 +40,7 @@ def load_config(config, additional_permitted_classes) end def all_hashes? - !safe_config.empty? && safe_config.all?(Hash) + safe_config.all?(Hash) end def too_many_documents? diff --git a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb index c8e52d86890140..bae98f9bc35575 100644 --- a/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/multi_doc_yaml_spec.rb @@ -24,6 +24,14 @@ ]) end + context 'when the YAML file is empty' do + let(:yml) { '' } + + it 'returns an empty array' do + expect(loader.load!).to be_empty + end + end + context 'when the parsed YAML is too big' do let(:yml) do <<~YAML -- GitLab