diff --git a/ee/lib/search/elastic/dsl/models/base.rb b/ee/lib/search/elastic/dsl/models/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..79cedc9e73875d7627f8d8303684a76d8edf10da --- /dev/null +++ b/ee/lib/search/elastic/dsl/models/base.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Search + module Elastic + module Dsl + module Models + class Base + include ::Search::Elastic::Dsl::SchemaVersions + + class << self + private + + def mapped?(name) + ::Elastic::DataMigrationService.migration_has_finished?(name) + end + end + end + end + end + end +end diff --git a/ee/lib/search/elastic/dsl/models/vulnerability.rb b/ee/lib/search/elastic/dsl/models/vulnerability.rb new file mode 100644 index 0000000000000000000000000000000000000000..951449ea327e63d9f0dd96148764d0cb786c3807 --- /dev/null +++ b/ee/lib/search/elastic/dsl/models/vulnerability.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Search + module Elastic + module Dsl + module Models + class Vulnerability < Base + SCHEMA_VERSION = 25_43 + + schema_versions do + version 25_25 + version 25_26, if: -> { mapped?(:add_rechability_field_to_vulnerability) } + version 25_36, if: -> { mapped?(:add_resolved_at_dismissed_at_fields_to_vulnerability) } + version 25_37, if: -> { mapped?(:add_token_status_field_to_vulnerability) } + version 25_42, if: -> { mapped?(:add_policy_violations_field_to_vulnerability) } + version SCHEMA_VERSION, if: -> { mapped?(:add_risk_score_field_to_vulnerability) } + end + end + end + end + end +end diff --git a/ee/lib/search/elastic/dsl/schema_versions.rb b/ee/lib/search/elastic/dsl/schema_versions.rb new file mode 100644 index 0000000000000000000000000000000000000000..5272ebc29e3cbc320879ab22fbe191b83b1dccd9 --- /dev/null +++ b/ee/lib/search/elastic/dsl/schema_versions.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Search::Elastic::Dsl::SchemaVersions +# +# Provides a DSL to declare schema versions for Elasticsearch models. +# Allows conditional versions that depend on migration or feature state. +# Example usage: +# schema_versions do +# version 2525 +# version 2526, if: -> { migration_has_finished?(:some_migration) } +# end +module Search + module Elastic + module Dsl + module SchemaVersions + extend ActiveSupport::Concern + + included do + # Internal storage for schema version definitions + # Each entry: { version: , condition: } + class_attribute :_schema_versions, instance_accessor: false, default: [] + + # Ensure subclasses get independent copies of schema versions + singleton_class.prepend(Module.new do + def inherited(subclass) + super + subclass._schema_versions = _schema_versions.dup + end + end) + end + + class_methods do + # DSL entrypoint: accepts a block to define multiple versions + # Example: + # schema_versions do + # version 2525 + # version 2526, if: -> { some_condition } + # end + def schema_versions(&block) + return _schema_versions unless block + + class_exec(&block) + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: name) + raise e + end + + # Return the currently active schema version + # Active versions are those without a condition or where condition.call == true + # Returns the maximum active version, or the last unconditional version + def current_version + return if _schema_versions.blank? + + active = _schema_versions.filter_map do |entry| + entry[:version] if entry[:condition].nil? || entry[:condition].call + end + + # Return highest active version, fallback to last unconditional version + return active.max if active.any? + + _schema_versions.reverse.find { |v| v[:condition].nil? }&.dig(:version) + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: name) + raise e + end + + private + + # Register a single version + # number - version number (Integer) + # opts[:if] - optional Proc, only active if true + def version(number, **opts) + _schema_versions << { version: number, condition: opts[:if] } + end + end + end + end + end +end diff --git a/ee/lib/search/elastic/references/vulnerability.rb b/ee/lib/search/elastic/references/vulnerability.rb index 3d426a210f0135384e78f9aa0729ed237634dfcf..5cf0aee509a68de695c7b8eca51e091d88761e43 100644 --- a/ee/lib/search/elastic/references/vulnerability.rb +++ b/ee/lib/search/elastic/references/vulnerability.rb @@ -7,7 +7,7 @@ class Vulnerability < Reference include Search::Elastic::Concerns::DatabaseReference include ::Gitlab::Utils::StrongMemoize - SCHEMA_VERSION = 25_43 + SCHEMA_VERSION = ::Search::Elastic::Dsl::Models::Vulnerability::SCHEMA_VERSION DOC_TYPE = 'vulnerability' INDEX_NAME = 'vulnerabilities' @@ -137,25 +137,11 @@ def fetch_record_attribute(record, attribute) def internal_es_fields { - schema_version: fetch_schema_version, + schema_version: ::Search::Elastic::Dsl::Models::Vulnerability.current_version, type: DOC_TYPE }.with_indifferent_access end - def fetch_schema_version - if risk_score_migration_completed? - SCHEMA_VERSION - elsif policy_violations_migration_finished? - 25_42 - elsif token_status_migration_finished? - 25_37 - elsif resolved_at_dismissed_at_migration_completed? - 25_36 - else - 25_26 - end - end - def token_status_migration_finished? ::Elastic::DataMigrationService.migration_has_finished?( :add_token_status_field_to_vulnerability diff --git a/ee/spec/lib/search/elastic/dsl/models/vulnerability_spec.rb b/ee/spec/lib/search/elastic/dsl/models/vulnerability_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..70f8b30c200aad44b8f30f7d5f91de9a0e062bd6 --- /dev/null +++ b/ee/spec/lib/search/elastic/dsl/models/vulnerability_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../shared/models_shared_examples' + +RSpec.describe Search::Elastic::Dsl::Models::Vulnerability, feature_category: :global_search do + it_behaves_like( + 'a model with schema version logic for migrations', + [ + [25_25, nil], + [25_26, :add_rechability_field_to_vulnerability], + [25_36, :add_resolved_at_dismissed_at_fields_to_vulnerability], + [25_37, :add_token_status_field_to_vulnerability], + [25_42, :add_policy_violations_field_to_vulnerability], + [25_43, :add_risk_score_field_to_vulnerability] + ], + current_version: 25_43, + fallback_version: 25_25 + ) +end diff --git a/ee/spec/lib/search/elastic/dsl/schema_versions_spec.rb b/ee/spec/lib/search/elastic/dsl/schema_versions_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..87ed7d6ab035df9e7b09ea116c7f58a48dfc9edf --- /dev/null +++ b/ee/spec/lib/search/elastic/dsl/schema_versions_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'shared/models_shared_examples' + +RSpec.describe Search::Elastic::Dsl::SchemaVersions, feature_category: :global_search do + let(:klass) do + Class.new do + include Search::Elastic::Dsl::SchemaVersions + end + end + + describe '.current_version' do + it 'returns nil if no versions are defined' do + expect(klass.current_version).to be_nil + end + + it 'returns the latest version when all unconditional' do + klass.schema_versions do + version 1 + version 3 + version 2 + end + + expect(klass.current_version).to eq(3) + end + + it 'returns the highest active version when conditions pass' do + klass.schema_versions do + version 1, if: -> { false } + version 2, if: -> { true } + version 3, if: -> { true } + end + + expect(klass.current_version).to eq(3) + end + + it 'falls back to the last unconditional version when none active' do + klass.schema_versions do + version 5, if: -> { false } + version 7, if: -> { false } + version 4 + end + + expect(klass.current_version).to eq(4) + end + + it 'returns nil when all conditional versions evaluate to false and no unconditional versions exist' do + klass.schema_versions do + version 1, if: -> { false } + version 2, if: -> { false } + end + + expect(klass.current_version).to be_nil + end + + it 'ignores unconditional versions if higher conditional versions are active' do + klass.schema_versions do + version 1 + version 2, if: -> { true } + end + + expect(klass.current_version).to eq(2) + end + + it 'logs and raises on exception' do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + klass.schema_versions { version 9, if: -> { raise 'error' } } + + expect { klass.current_version }.to raise_error(RuntimeError, 'error') + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + end + + describe '.schema_versions' do + it 'evaluates a block to define versions' do + klass.schema_versions do + version 100 + version 200 + end + + expect(klass.schema_versions).to eq( + [ + { version: 100, condition: nil }, + { version: 200, condition: nil } + ] + ) + end + + it 'returns the versions list when called without a block' do + klass.schema_versions { version 11 } + + expect(klass.schema_versions).to eq([{ version: 11, condition: nil }]) + end + + it 'logs and raises error if block raises' do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + + expect { klass.schema_versions { raise 'error' } }.to raise_error(RuntimeError, 'error') + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + + it 'logs and raises but preserves previously defined versions' do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + + klass.schema_versions { version 1 } + + expect { klass.schema_versions { raise 'error' } }.to raise_error(RuntimeError, 'error') + expect(klass.schema_versions).to eq([{ version: 1, condition: nil }]) + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + end +end diff --git a/ee/spec/lib/search/elastic/dsl/shared/models_shared_examples.rb b/ee/spec/lib/search/elastic/dsl/shared/models_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..7374c176ad29fad1ca4673bd900f7147ea80f3ca --- /dev/null +++ b/ee/spec/lib/search/elastic/dsl/shared/models_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a model with schema version logic for migrations' do |expected_versions, + current_version:, fallback_version:| + describe '.current_version' do + subject(:version) { described_class.current_version } + + where(:expected_version, :migration) do + expected_versions + end + + with_them do + before do + allow(::Elastic::DataMigrationService) + .to receive(:migration_has_finished?).and_return(false) + + if migration + allow(::Elastic::DataMigrationService) + .to receive(:migration_has_finished?) + .with(migration) + .and_return(true) + end + end + + it( + "returns schema version #{params[:expected_version]} " \ + "when #{params[:migration] || 'no migration'} is finished" + ) do + expect(version).to eq(expected_version) + end + end + + context 'when no migrations are finished' do + it 'returns the fallback version' do + allow(::Elastic::DataMigrationService) + .to receive(:migration_has_finished?).and_return(false) + + expect(described_class.current_version).to eq(fallback_version) + end + end + + context 'when all migrations are finished' do + it 'returns the current (latest) schema version' do + allow(::Elastic::DataMigrationService) + .to receive(:migration_has_finished?).and_return(true) + + expect(described_class.current_version).to eq(current_version) + end + end + end +end