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..6afc37267bc053f341111575a875185d1e69a1ac --- /dev/null +++ b/ee/lib/ee/gitlab/ci/config/entry/job.rb @@ -0,0 +1,27 @@ +# 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 + 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 + 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..d2d9ff57f6c9816a4c86573d2b04058dded9bbdf --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets.rb @@ -0,0 +1,28 @@ +# 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::Configurable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[vault].freeze + + attributes ALLOWED_KEYS + + entry :vault, Entry::Secrets::Vault, description: 'Secrets managed by Vault' + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :vault, type: Hash, allow_nil: true + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd443d808e0624e1f69114475584866cf22f09a6 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + ## + # Entry that represents secrets managed by Vault. + # + class Vault < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + + def compose!(deps = nil) + super do + @config.each do |name, config| + factory = ::Gitlab::Config::Entry::Factory.new(Entry::Secrets::Vault::Server) + .value(config || {}) + .metadata(name: name) + .with(key: name, parent: self, description: "#{name} Vault server definition") # rubocop:disable CodeReuse/ActiveRecord + + @entries[name] = factory.create! + end + + @entries.each_value do |entry| + entry.compose!(deps) + end + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault/auth.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault/auth.rb new file mode 100644 index 0000000000000000000000000000000000000000..2a6d3275a9023a47b9bf922a03a947e563b978a9 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault/auth.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + class Vault + ## + # Entry that represents Vault auth configuration. + # + class Auth < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[name path data].freeze + + attributes ALLOWED_KEYS + + validations do + validates :config, type: Hash, allowed_keys: ALLOWED_KEYS + validates :name, type: String + validates :path, type: String + validates :data, type: Hash + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault/engine.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault/engine.rb new file mode 100644 index 0000000000000000000000000000000000000000..bfbd6de8739ff19b592da7650630e15885cdcede --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault/engine.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + class 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 + validates :name, presence: true, type: String + validates :path, presence: true, type: String + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault/secret.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault/secret.rb new file mode 100644 index 0000000000000000000000000000000000000000..41bb62a33449eec019062c2b9637f536aeea1560 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault/secret.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + class Vault + ## + # Entry that represents Vault secret. + # + class Secret < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[engine path fields strategy].freeze + + attributes ALLOWED_KEYS + + entry :engine, Entry::Secrets::Vault::Engine, description: 'Vault secrets engine configuration' + + validations do + validates :config, type: Hash, allowed_keys: ALLOWED_KEYS + validates :path, presence: true, type: String + validates :fields, presence: true, array_of_strings: true + + validates :strategy, presence: true, allowed_values: ['read'] + validates :engine, presence: true, type: Hash + end + + def value + { + engine: engine_value, + path: path, + fields: fields, + strategy: strategy + } + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault/secrets.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault/secrets.rb new file mode 100644 index 0000000000000000000000000000000000000000..d39b60a41252e1552f42c56628175a47871f8caf --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault/secrets.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + class Vault + ## + # Entry that represents collection of Vault secrets. + # + class Secrets < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + + def compose!(deps = nil) + super do + @config.each do |name, config| + factory = ::Gitlab::Config::Entry::Factory.new(Entry::Secrets::Vault::Secret) + .value(config || {}) + .metadata(name: name) + .with(key: name, parent: self, description: "#{name} Vault secret definition") # rubocop:disable CodeReuse/ActiveRecord + + @entries[name] = factory.create! + end + + @entries.each_value do |entry| + entry.compose!(deps) + end + end + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/config/entry/secrets/vault/server.rb b/ee/lib/gitlab/ci/config/entry/secrets/vault/server.rb new file mode 100644 index 0000000000000000000000000000000000000000..f280090fe39cbc07a0457487b9505af7be5934e0 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/secrets/vault/server.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Secrets + class Vault + ## + # Entry that represents Vault server. + # + class Server < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[url auth secrets].freeze + + attributes ALLOWED_KEYS + + entry :auth, Entry::Secrets::Vault::Auth, description: 'Vault auth configuration' + entry :secrets, Entry::Secrets::Vault::Secrets, description: 'Vault secrets for a job' + + validations do + validates :config, type: Hash, allowed_keys: ALLOWED_KEYS + validates :url, presence: true, addressable_url: true + validates :secrets, type: Hash + validates :auth, type: Hash + end + + def value + { + url: url, + auth: auth_value, + secrets: secrets_value + } + 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 9502fb9b5214b3e4229cc69cf0972e90c97fd499..dbe9b247d56be8c36fd64a7004b86acc8e387dc5 100644 --- a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -10,6 +10,14 @@ entry.compose! end + context 'when entry value is correct' do + context 'when has secrets' do + let(:config) { { script: 'echo', secrets: {} } } + + it { expect(entry).to be_valid } + end + end + context 'when entry value is not correct' do context 'when has needs' do context 'when needs is bridge type' do @@ -27,6 +35,59 @@ 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 + { + vault: { + db_vault: { + url: 'https://db.vault.example.com', + auth: { + name: 'jwt', + path: 'jwt', + data: { role: 'production' } + }, + secrets: { + DATABASE_CREDENTIALS: { + engine: { name: 'kv-v2', path: 'kv-v2' }, + path: 'production/db', + fields: %w(username password), + strategy: 'read' + } + } + } + } + } + end + + before do + entry.compose! + end + + it 'includes secrets value' do + expect(entry.value[:secrets]).to eq(secrets) end end end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/auth_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/auth_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9bc3b43621c1d4c97a3dfd453f072e89bcfd3920 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/auth_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::Vault::Auth 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: 'jwt', path: 'jwt', data: { jwt: 'secret', role: 'production' } } } + + describe '#value' do + it 'returns Vault auth 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 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 'auth config contains unknown keys: foo' + end + end + + context 'when config is of incorrect type' do + let(:config) { [] } + + it 'reports error' do + expect(entry.errors) + .to include 'auth config should be a hash' + end + end + + context 'when data is not hash' do + let(:config) { { data: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'auth data should be a hash' + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/engine_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/engine_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..434c5bc4f8680ada984179d7f24a53a883f5b4ab --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/engine_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::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 is not present' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include 'engine name can\'t be blank' + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secret_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secret_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..126808ff6f43c529d128985b193cd7fb93d29848 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secret_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::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(:config) do + { + engine: { + name: 'kv-v2', + path: 'kv-v2' + }, + path: 'production/db', + fields: %w(username password), + strategy: 'read' + } + end + + describe '#value' do + it 'returns Vault 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: :bar } } + + it 'reports error' do + expect(entry.errors) + .to include 'secret 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 'secret path can\'t be blank' + end + end + + context 'when fields are not present' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include 'secret fields can\'t be blank' + end + end + + context 'when fields are not a list' do + let(:config) { { fields: {} } } + + it 'reports error' do + expect(entry.errors) + .to include 'secret fields should be an array of strings' + end + end + + context 'when engine is not a hash' do + let(:config) { { engine: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'secret engine should be a hash' + end + end + + context 'when strategy is not allowed value' do + let(:config) { { strategy: 'write' } } + + it 'reports error' do + expect(entry.errors) + .to include 'secret strategy unknown value: write' + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secrets_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secrets_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f0ed8d3d5919d1f0e2d1cecc9a3f2a04fa16fba0 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/secrets_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::Vault::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 Vault 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 vault entries composed' do + let(:config) do + { + DATABASE_CREDENTIALS: { engine: { name: 'kv-v2', path: 'kv-v2' }, fields: %w(username password), path: 'production/db', strategy: 'read' }, + SSL_CERTS: { engine: { name: 'kv-v2', path: 'kv-v2' }, fields: %w(private_key), path: 'production/ssl', strategy: 'read' } + } + 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::Secrets::Vault::Secret)) + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/server_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/server_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a8f82800c740da6c97d1c5ac45bf4e53cd194b7c --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault/server_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::Vault::Server 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 + { + url: 'https://vault.example.com', + auth: { name: 'jwt', path: 'jwt', data: { role: 'production' } }, + secrets: {} + } + end + + describe '#value' do + it 'returns Vault server 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 url is not addressable' do + let(:config) { { url: 'vault.example.com' } } + + it 'reports error' do + expect(entry.errors) + .to include 'server url is blocked: only allowed schemes are http, https' + end + end + + context 'when url is not present' do + let(:config) { {} } + + it 'reports error' do + expect(entry.errors) + .to include "server url can't be blank" + end + end + + context 'when there is an unknown key present' do + let(:config) { { foo: :bar } } + + it 'reports error' do + expect(entry.errors) + .to include 'server config contains unknown keys: foo' + end + end + + context 'when config is of incorrect type' do + let(:config) { [] } + + it 'reports error' do + expect(entry.errors) + .to include 'server config should be a hash' + end + end + + context 'when auth is not hash' do + let(:config) { { url: 'https://vault.example.com', auth: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'server auth should be a hash' + end + end + + context 'when secrets is not hash' do + let(:config) { { url: 'https://vault.example.com', secrets: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'server secrets should be a hash' + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/secrets/vault_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b5158027aa6fe78df7a1c85f5b7eb77dd22f5b4b --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets/vault_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Secrets::Vault 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 Vault provider 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 'vault config should be a hash' + end + end + end + + describe '#compose!' do + context 'when valid vault entries composed' do + let(:config) do + { + vault_1: { + url: 'https://vault_1.example.com', + auth: { name: 'jwt', path: 'jwt', data: { role: 'production' } }, + secrets: {} + }, + vault_2: { + url: 'https://vault_2.example.com', + auth: { name: 'jwt', path: 'jwt', data: { role: 'production' } }, + secrets: {} + } + } + 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::Secrets::Vault::Server)) + 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..7531a7726158b3793bf4acb7db4a05fc90e638ed --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/secrets_spec.rb @@ -0,0 +1,47 @@ +# 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) { { vault: {} } } + + 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 vault attribute is of incorrect type' do + let(:config) { { vault: [] } } + + it 'reports error' do + expect(entry.errors) + .to include 'secrets vault should be a hash' + end + end + + context 'when there is an unknown key present' do + let(:config) { { foo: {} } } + + it 'reports error' do + expect(entry.errors) + .to include 'secrets config contains unknown keys: foo' + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 1ea594913785034f5667b2b09f77eb7d79f04e23..f7c643becff81ea1c97b35e8cd2fafad92f985c3 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/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