diff --git a/ee/lib/ee/gitlab/ci/config/entry/job.rb b/ee/lib/ee/gitlab/ci/config/entry/job.rb new file mode 100644 index 0000000000000000000000000000000000000000..2271c72a9ee9c9b2cfa9368c731346a52a22ad15 --- /dev/null +++ b/ee/lib/ee/gitlab/ci/config/entry/job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module Config + module Entry + module Job + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + prepended do + attributes :secrets + + validations do + validates :secrets, absence: { message: 'feature is disabled' }, unless: :secrets_enabled? + end + + entry :secrets, ::Gitlab::Ci::Config::Entry::Secrets, + description: 'Configured secrets for this job', + inherit: false + end + + override :value + def value + super.merge({ secrets: secrets_value }.compact) + end + + def secrets_enabled? + ::Gitlab::Ci::Features.secrets_syntax_enabled? + end + end + end + end + end + end +end diff --git a/ee/lib/ee/gitlab/ci/features.rb b/ee/lib/ee/gitlab/ci/features.rb new file mode 100644 index 0000000000000000000000000000000000000000..33daaa938d9a780b9876ec6adf736d710df8f6b2 --- /dev/null +++ b/ee/lib/ee/gitlab/ci/features.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module Features + extend ActiveSupport::Concern + + prepended do + def self.secrets_syntax_enabled? + ::Feature.enabled?(:ci_secrets_syntax) + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secret.rb b/ee/lib/gitlab/ci/config/entry/secret.rb new file mode 100644 index 0000000000000000000000000000000000000000..48f309b003caec3dbf356c1e599f5f296ca48a1b --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secret.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a secret definition. + # + class Secret < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[vault].freeze + + attributes ALLOWED_KEYS + + entry :vault, Entry::Vault::Secret, description: 'Vault secrets engine configuration' + + validations do + validates :config, allowed_keys: ALLOWED_KEYS, required_keys: ALLOWED_KEYS + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets.rb b/ee/lib/gitlab/ci/config/entry/secrets.rb new file mode 100644 index 0000000000000000000000000000000000000000..02438b4a6b22c49e1331a844510f0c1d2672499f --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a secrets definition. + # + class Secrets < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, type: Hash + end + + def compose!(deps = nil) + super do + @config.each do |name, config| + factory = ::Gitlab::Config::Entry::Factory.new(Entry::Secret) + .value(config || {}) + .with(key: name, parent: self, description: "#{name} secret definition") # rubocop:disable CodeReuse/ActiveRecord + .metadata(name: name) + + @entries[name] = factory.create! + end + + @entries.each_value do |entry| + entry.compose!(deps) + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/vault/engine.rb b/ee/lib/gitlab/ci/config/entry/vault/engine.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b186f18dcacfa07cc30409c623a767397c5a1d1 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/vault/engine.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + module Vault + ## + # Entry that represents Vault secret engine. + # + class Engine < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[name path].freeze + + attributes ALLOWED_KEYS + + validations do + validates :config, type: Hash, allowed_keys: ALLOWED_KEYS, required_keys: ALLOWED_KEYS + validates :name, presence: true, type: String + validates :path, presence: true, type: String + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/vault/secret.rb b/ee/lib/gitlab/ci/config/entry/vault/secret.rb new file mode 100644 index 0000000000000000000000000000000000000000..ebd2ae405f4f0837494479efb1c9d240a5488f20 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/vault/secret.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + module Vault + ## + # Entry that represents Vault secret. + # + class Secret < ::Gitlab::Config::Entry::Simplifiable + strategy :StringStrategy, if: -> (config) { config.is_a?(String) } + strategy :HashStrategy, if: -> (config) { config.is_a?(Hash) } + + class UnknownStrategy < ::Gitlab::Config::Entry::Node + def errors + ["#{location} should be a hash or a string"] + end + end + + class StringStrategy < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + validates :config, type: String + end + + def value + { + engine: { + name: 'kv-v2', path: secret[:engine_path] + }, + path: secret[:secret_path], + field: secret[:secret_field] + } + end + + private + + def secret + @secret ||= begin + secret, engine_path = secret_and_engine + secret_path, _, secret_field = secret.rpartition('/') + + { + engine_path: engine_path, + secret_path: secret_path, + secret_field: secret_field + } + end + end + + def secret_and_engine + secret, _, engine = config.rpartition('@') + + if secret == "" + secret = config + engine = 'kv-v2' + end + + [secret, engine] + end + end + + class HashStrategy < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[engine path field].freeze + + attributes ALLOWED_KEYS + + entry :engine, Entry::Vault::Engine, description: 'Vault secrets engine configuration' + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :path, presence: true, type: String + validates :field, presence: true, type: String + validates :engine, presence: true, type: Hash + end + + def value + { + engine: engine_value, + path: path, + field: field + } + end + end + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb index 5a196e917575c1100836bf4cc688fc9ef24b8458..cfb72efcdec71d8f1e93ed1013c598677fbcf594 100644 --- a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -6,11 +6,40 @@ let(:entry) { described_class.new(config, name: :rspec) } describe 'validations' do - before do - entry.compose! + context 'when entry value is correct' do + context 'when has secrets' do + let(:config) { { script: 'echo', secrets: { DATABASE_PASSWORD: { vault: 'production/db/password' } } } } + + context 'when ci_secrets_syntax feature flag is enabled' do + before do + stub_feature_flags(ci_secrets_syntax: true) + entry.compose! + end + + it { expect(entry).to be_valid } + end + + context 'when ci_secrets_syntax feature flag is disabled' do + before do + stub_feature_flags(ci_secrets_syntax: false) + entry.compose! + end + + it 'returns an error' do + aggregate_failures do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job secrets feature is disabled' + end + end + end + end end context 'when entry value is not correct' do + before do + entry.compose! + end + context 'when has needs' do context 'when needs is bridge type' do let(:config) do @@ -27,6 +56,73 @@ end end end + + context 'when has invalid secrets' do + let(:config) { { script: 'echo', secrets: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'secrets config should be a hash' + end + end + end + end + + describe '.nodes' do + context 'when filtering all the entry/node names' do + subject(:nodes) { described_class.nodes } + + it 'has "secrets" node' do + expect(nodes).to have_key(:secrets) + end + end + end + + describe 'secrets' do + let(:config) { { script: 'echo', secrets: secrets } } + let(:secrets) do + { + DATABASE_PASSWORD: { vault: 'production/db/password' }, + SSL_PRIVATE_KEY: { vault: 'production/ssl/private-key@ops' }, + S3_SECRET_KEY: { + vault: { + engine: { name: 'kv-v2', path: 'aws' }, + path: 'production/s3', + field: 'secret-key' + } + } + } + end + + before do + entry.compose! + end + + it 'includes secrets value' do + expect(entry.errors).to be_empty + expect(entry.value[:secrets]).to eq({ + DATABASE_PASSWORD: { + vault: { + engine: { name: 'kv-v2', path: 'kv-v2' }, + path: 'production/db', + field: 'password' + } + }, + SSL_PRIVATE_KEY: { + vault: { + engine: { name: 'kv-v2', path: 'ops' }, + path: 'production/ssl', + field: 'private-key' + } + }, + S3_SECRET_KEY: { + vault: { + engine: { name: 'kv-v2', path: 'aws' }, + path: 'production/s3', + field: 'secret-key' + } + } + }) end end end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secret_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secret_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..58fb437ddaecdd670f3f6eaceba71d6283eadc6b --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secret_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secret do + let(:entry) { described_class.new(config) } + + describe 'validation' do + before do + entry.compose! + end + + context 'when entry config value is correct' do + let(:config) do + { + vault: { + engine: { name: 'kv-v2', path: 'kv-v2' }, + path: 'production/db', + field: 'password' + } + } + end + + describe '#value' do + it 'returns secret configuration' do + expect(entry.value).to eq(config) + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when there is an unknown key present' do + let(:config) { { foo: {} } } + + it 'reports error' do + expect(entry.errors) + .to include 'secret config contains unknown keys: foo' + end + end + + context 'when there is no vault entry' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include 'secret config missing required keys: vault' + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6e24324d8711dd01fdf72926e9db679c7fa05b8b --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets do + let(:entry) { described_class.new(config) } + + describe 'validation' do + before do + entry.compose! + end + + context 'when entry config value is correct' do + let(:config) { {} } + + describe '#value' do + it 'returns secrets configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is of incorrect type' do + let(:config) { [] } + + it 'reports error' do + expect(entry.errors) + .to include 'secrets config should be a hash' + end + end + end + + describe '#compose!' do + context 'when valid secret entries composed' do + let(:config) do + { + DATABASE_PASSWORD: { + vault: { + engine: { name: 'kv-v2', path: 'kv-v2' }, + path: 'production/db', + field: 'password' + } + } + } + end + + before do + entry.compose! + end + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq(config) + end + end + + describe '#descendants' do + it 'creates valid descendant nodes' do + expect(entry.descendants).to all(be_a(Gitlab::Ci::Config::Entry::Secret)) + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/vault/engine_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/vault/engine_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f8e17d590720654d1574e369fe681a5d3f040f7a --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/vault/engine_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Vault::Engine do + let(:entry) { described_class.new(config) } + + describe 'validation' do + before do + entry.compose! + end + + context 'when entry config value is correct' do + let(:config) { { name: 'kv-v2', path: 'kv-v2' } } + + describe '#value' do + it 'returns Vault secret engine configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when there is an unknown key present' do + let(:config) { { foo: :bar } } + + it 'reports error' do + expect(entry.errors) + .to include 'engine config contains unknown keys: foo' + end + end + + context 'when name and path are missing' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors).to include 'engine config missing required keys: name, path' + end + end + + context 'when name and path are blank' do + let(:config) { { name: '', path: '' } } + + it 'reports error' do + aggregate_failures do + expect(entry.errors).to include 'engine name can\'t be blank' + expect(entry.errors).to include 'engine path can\'t be blank' + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/vault/secret_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/vault/secret_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7fe164eb4a1f31ea76a195efdec2c4615a11373c --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/vault/secret_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Vault::Secret do + let(:entry) { described_class.new(config) } + + describe 'validation' do + before do + entry.compose! + end + + context 'when entry config value is correct' do + let(:hash_config) do + { + engine: { + name: 'kv-v2', + path: 'some/path' + }, + path: 'production/db', + field: 'password' + } + end + + context 'when config is a hash' do + let(:config) { hash_config } + + describe '#value' do + it 'returns Vault secret configuration' do + expect(entry.value).to eq(hash_config) + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is a string with engine path' do + let(:config) { 'production/db/password@some/path' } + + describe '#value' do + it 'returns Vault secret configuration' do + expect(entry.value).to eq(hash_config) + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is a string without engine path' do + let(:config) { 'production/db/password' } + + describe '#value' do + it 'returns Vault secret configuration' do + expect(entry.value).to eq(hash_config.deep_merge(engine: { path: 'kv-v2' })) + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when there is an unknown key present' do + let(:config) { { foo: :bar } } + + it 'reports error' do + expect(entry.errors) + .to include 'hash strategy config contains unknown keys: foo' + end + end + + context 'when path is not present' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include 'hash strategy path can\'t be blank' + end + end + + context 'when field is not present' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include 'hash strategy field can\'t be blank' + end + end + + context 'when engine is not a hash' do + let(:config) { { engine: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'hash strategy engine should be a hash' + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 66050a7bbe0d46baa9c1f6e0a0120e896e44ccb2..a615cab1a802bb159a8e87a5e08f5f8578bf6ca0 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -15,7 +15,7 @@ class Job < ::Gitlab::Config::Entry::Node allow_failure type when start_in artifacts cache dependencies before_script needs after_script environment coverage retry parallel interruptible timeout - resource_group release].freeze + resource_group release secrets].freeze REQUIRED_BY_NEEDS = %i[stage].freeze @@ -191,3 +191,5 @@ def value end end end + +::Gitlab::Ci::Config::Entry::Job.prepend_if_ee('::EE::Gitlab::Ci::Config::Entry::Job') diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb index 5d6cf54e610d9092a0672a0d53a0be76001953b9..89d0e31a05c392af439334fd97f87314c214f0f9 100644 --- a/lib/gitlab/ci/features.rb +++ b/lib/gitlab/ci/features.rb @@ -44,3 +44,5 @@ def self.release_generation_enabled? end end end + +::Gitlab::Ci::Features.prepend_if_ee('::EE::Gitlab::Ci::Features') diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index b62794854268cc315b9ea4d032961772e1399a7b..a721f86bb492578aa2caf86b041ecd6670494257 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -33,7 +33,7 @@ inherit] end - it { is_expected.to match_array result } + it { is_expected.to include(*result) } end end