schema-tools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,263 @@
1
+ require 'json'
2
+ require_relative 'json_diff'
3
+ require_relative 'schema_files'
4
+ require_relative 'client'
5
+ require_relative 'settings_filter'
6
+
7
+ module SchemaTools
8
+ def self.diff_all_schemas(client:)
9
+ Diff.diff_all_schemas(client)
10
+ end
11
+
12
+ def self.diff_schema(alias_name, client:)
13
+ print_schema_diff(Diff.generate_schema_diff(alias_name, client))
14
+ nil # Console output method, returns nil
15
+ end
16
+
17
+ class Diff
18
+ # Compare all schemas to their corresponding downloaded alias settings and mappings
19
+ def self.diff_all_schemas(client)
20
+ schemas = SchemaFiles.discover_all_schemas
21
+
22
+ if schemas.empty?
23
+ puts "No schemas found in #{Config.schemas_path}"
24
+ return
25
+ end
26
+
27
+ puts "Found #{schemas.length} schema(s) to compare:"
28
+ schemas.each do |schema|
29
+ puts " - #{schema}"
30
+ end
31
+ puts
32
+
33
+ schemas.each do |alias_name|
34
+ begin
35
+ print_schema_diff(generate_schema_diff(alias_name, client))
36
+ rescue => e
37
+ puts "✗ Diff failed for #{alias_name}: #{e.message}"
38
+ end
39
+ puts
40
+ end
41
+ end
42
+
43
+ # Generate a nicely formatted diff representation for a single schema
44
+ # Compare a single schema to its corresponding downloaded alias settings and mappings
45
+ def self.generate_schema_diff(alias_name, client)
46
+ result = {
47
+ alias_name: alias_name,
48
+ status: nil,
49
+ settings_diff: nil,
50
+ mappings_diff: nil,
51
+ error: nil
52
+ }
53
+ json_diff = JsonDiff.new
54
+
55
+ begin
56
+ unless client.alias_exists?(alias_name)
57
+ result[:status] = :alias_not_found
58
+ result[:error] = "Alias '#{alias_name}' not found in cluster"
59
+ return result
60
+ end
61
+
62
+ alias_indices = client.get_alias_indices(alias_name)
63
+
64
+ if alias_indices.length > 1
65
+ result[:status] = :multiple_indices
66
+ result[:error] = "Alias '#{alias_name}' points to multiple indices: #{alias_indices.join(', ')}. This configuration is not supported for diffing."
67
+ return result
68
+ end
69
+
70
+ index_name = alias_indices.first
71
+
72
+ local_settings = SchemaFiles.get_settings(alias_name)
73
+ local_mappings = SchemaFiles.get_mappings(alias_name)
74
+
75
+ if local_settings.nil? || local_mappings.nil?
76
+ result[:status] = :local_files_not_found
77
+ result[:error] = "Local schema files not found for #{alias_name}"
78
+ return result
79
+ end
80
+
81
+ remote_settings = client.get_index_settings(index_name)
82
+ remote_mappings = client.get_index_mappings(index_name)
83
+
84
+ if remote_settings.nil? || remote_mappings.nil?
85
+ result[:status] = :remote_fetch_failed
86
+ result[:error] = "Failed to retrieve remote settings or mappings for #{index_name}"
87
+ return result
88
+ end
89
+
90
+ # Filter remote settings to match local format
91
+ filtered_remote_settings = SettingsFilter.filter_internal_settings(remote_settings)
92
+
93
+ # Normalize both local and remote settings to ensure consistent comparison
94
+ normalized_local_settings = self.normalize_local_settings(local_settings)
95
+ normalized_remote_settings = self.normalize_remote_settings(filtered_remote_settings)
96
+
97
+ result[:settings_diff] = json_diff.generate_diff(normalized_remote_settings, normalized_local_settings)
98
+ result[:mappings_diff] = json_diff.generate_diff(remote_mappings, local_mappings)
99
+
100
+ result[:comparison_context] = {
101
+ new_files: {
102
+ settings: "#{alias_name}/settings.json",
103
+ mappings: "#{alias_name}/mappings.json"
104
+ },
105
+ old_api: {
106
+ settings: "GET /#{index_name}/_settings",
107
+ mappings: "GET /#{index_name}/_mappings"
108
+ }
109
+ }
110
+
111
+ if result[:settings_diff] == "No changes detected" && result[:mappings_diff] == "No changes detected"
112
+ result[:status] = :no_changes
113
+ else
114
+ result[:status] = :changes_detected
115
+ end
116
+
117
+ rescue => e
118
+ result[:status] = :error
119
+ result[:error] = e.message
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ # print_schema_diff(generate_schema_diff(alias_name))
126
+ def self.print_schema_diff(schema_diff)
127
+ puts "=" * 60
128
+ puts "Comparing schema: #{schema_diff[:alias_name]}"
129
+ puts "=" * 60
130
+
131
+ # Handle errors by printing and returning
132
+ if schema_diff[:status] == :alias_not_found
133
+ puts "❌ #{schema_diff[:error]}"
134
+ return
135
+ elsif schema_diff[:status] == :multiple_indices
136
+ puts "⚠️ #{schema_diff[:error]}"
137
+ return
138
+ elsif schema_diff[:status] == :local_files_not_found
139
+ puts "❌ #{schema_diff[:error]}"
140
+ return
141
+ elsif schema_diff[:status] == :remote_fetch_failed
142
+ puts "❌ #{schema_diff[:error]}"
143
+ return
144
+ elsif schema_diff[:status] == :error
145
+ puts "❌ Error: #{schema_diff[:error]}"
146
+ return
147
+ end
148
+
149
+ # Show what's being compared
150
+ puts "New (Local Files):"
151
+ puts " #{schema_diff[:comparison_context][:new_files][:settings]}"
152
+ puts " #{schema_diff[:comparison_context][:new_files][:mappings]}"
153
+ puts
154
+ puts "Old (Remote API):"
155
+ puts " #{schema_diff[:comparison_context][:old_api][:settings]}"
156
+ puts " #{schema_diff[:comparison_context][:old_api][:mappings]}"
157
+ puts
158
+
159
+ # Display the diffs
160
+ puts "Settings Comparison:"
161
+ puts schema_diff[:settings_diff]
162
+ puts
163
+
164
+ puts "Mappings Comparison:"
165
+ puts schema_diff[:mappings_diff]
166
+ end
167
+
168
+ private
169
+
170
+ def self.normalize_local_settings(local_settings)
171
+ return local_settings unless local_settings.is_a?(Hash)
172
+
173
+ # If local settings already have "index" wrapper, use it
174
+ if local_settings.key?("index")
175
+ if local_settings["index"].is_a?(Hash)
176
+ # Normalize the index settings and return
177
+ normalized_index = normalize_values(local_settings["index"])
178
+ return { "index" => normalized_index }
179
+ else
180
+ # If index exists but is not a hash, return as-is (invalid format)
181
+ return local_settings
182
+ end
183
+ end
184
+
185
+ # If local settings are empty, don't add index wrapper
186
+ # This prevents empty {} from becoming { "index" => {} }
187
+ return local_settings if local_settings.empty?
188
+
189
+ # If local settings don't have "index" wrapper, wrap them in "index"
190
+ # This handles cases like { "number_of_shards": 1 } which should be compared as { "index": { "number_of_shards": 1 } }
191
+ normalized_settings = normalize_values(local_settings)
192
+ { "index" => normalized_settings }
193
+ end
194
+
195
+ # Normalize remote settings to ensure consistent comparison
196
+ # Remote settings may come back as strings from ES API, so normalize them too
197
+ def self.normalize_remote_settings(remote_settings)
198
+ return remote_settings unless remote_settings.is_a?(Hash)
199
+
200
+ # If remote settings already have "index" wrapper, normalize it
201
+ if remote_settings.key?("index")
202
+ if remote_settings["index"].is_a?(Hash)
203
+ normalized_index = normalize_values(remote_settings["index"])
204
+ return { "index" => normalized_index }
205
+ else
206
+ return remote_settings
207
+ end
208
+ end
209
+
210
+ # If remote settings don't have "index" wrapper, normalize and wrap them
211
+ normalized_settings = normalize_values(remote_settings)
212
+ { "index" => normalized_settings }
213
+ end
214
+
215
+ # Normalize string values to their proper types for Elasticsearch comparison
216
+ # Handles the gotcha cases where ES accepts string inputs but returns canonical types
217
+ def self.normalize_values(obj)
218
+ case obj
219
+ when Hash
220
+ normalized = {}
221
+ obj.each do |key, value|
222
+ normalized[key] = normalize_values(value)
223
+ end
224
+ normalized
225
+ when Array
226
+ obj.map { |item| normalize_values(item) }
227
+ when String
228
+ normalize_string_value(obj)
229
+ else
230
+ obj
231
+ end
232
+ end
233
+
234
+ # Convert string values to their proper types based on Elasticsearch behavior
235
+ def self.normalize_string_value(str)
236
+ # Handle boolean values
237
+ case str.downcase
238
+ when "true"
239
+ true
240
+ when "false"
241
+ false
242
+ when "1"
243
+ # Could be boolean true or numeric 1, default to numeric for settings
244
+ 1
245
+ when "0"
246
+ # Could be boolean false or numeric 0, default to numeric for settings
247
+ 0
248
+ else
249
+ # Handle numeric strings
250
+ if str.match?(/\A-?\d+\z/)
251
+ # Integer string
252
+ str.to_i
253
+ elsif str.match?(/\A-?\d*\.\d+\z/)
254
+ # Float string
255
+ str.to_f
256
+ else
257
+ # Keep as string if it doesn't match any pattern
258
+ str
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,114 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require_relative 'config'
4
+ require_relative 'settings_filter'
5
+
6
+ module SchemaTools
7
+ def self.download(client:)
8
+ aliases = client.list_aliases
9
+ indices = client.list_indices
10
+
11
+ single_aliases = aliases.select { |alias_name, indices| indices.length == 1 && !alias_name.start_with?('.') }
12
+ multi_aliases = aliases.select { |alias_name, indices| indices.length > 1 && !alias_name.start_with?('.') }
13
+ unaliased_indices = indices.reject { |index| aliases.values.flatten.include?(index) || index.start_with?('.') || client.index_closed?(index) }
14
+
15
+ # Create a combined list with sequential numbering
16
+ options = []
17
+
18
+ puts "\nAliases pointing to 1 index:"
19
+ if single_aliases.empty?
20
+ puts " (none)"
21
+ else
22
+ single_aliases.each_with_index do |(alias_name, indices), index|
23
+ option_number = options.length + 1
24
+ options << { type: :alias, name: alias_name, index: indices.first }
25
+ puts " #{option_number}. #{alias_name} -> #{indices.first}"
26
+ end
27
+ end
28
+
29
+ puts "\nIndexes not part of any aliases:"
30
+ if unaliased_indices.empty?
31
+ puts " (none)"
32
+ else
33
+ unaliased_indices.each_with_index do |index_name, index|
34
+ option_number = options.length + 1
35
+ options << { type: :index, name: index_name, index: index_name }
36
+ puts " #{option_number}. #{index_name}"
37
+ end
38
+ end
39
+
40
+ if multi_aliases.any?
41
+ puts "\nAliases pointing to more than 1 index (cannot choose):"
42
+ multi_aliases.each do |alias_name, indices|
43
+ puts " - #{alias_name} -> #{indices.join(', ')}"
44
+ end
45
+ end
46
+
47
+ if options.empty?
48
+ puts "\nNo aliases or indices available to download."
49
+ return
50
+ end
51
+
52
+ puts "\nPlease choose an alias or index to download:"
53
+ puts "Enter the number (1-#{options.length}):"
54
+
55
+ choice = STDIN.gets&.chomp
56
+ if choice.nil?
57
+ puts "No input provided. Exiting."
58
+ exit 1
59
+ end
60
+
61
+ choice_num = choice.to_i
62
+ if choice_num < 1 || choice_num > options.length
63
+ puts "Invalid choice. Please enter a number between 1 and #{options.length}."
64
+ exit 1
65
+ end
66
+
67
+ selected_option = options[choice_num - 1]
68
+
69
+ if selected_option[:type] == :alias
70
+ download_alias(selected_option[:name], selected_option[:index], client)
71
+ else
72
+ download_index(selected_option[:name], client)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def self.download_alias(alias_name, index_name, client)
79
+ puts "Downloading alias '#{alias_name}' (index: #{index_name})..."
80
+ download_schema(alias_name, index_name, client)
81
+ end
82
+
83
+ def self.download_index(index_name, client)
84
+ puts "Downloading index '#{index_name}'..."
85
+ download_schema(index_name, index_name, client)
86
+ end
87
+
88
+ def self.download_schema(folder_name, index_name, client)
89
+ settings = client.get_index_settings(index_name)
90
+ mappings = client.get_index_mappings(index_name)
91
+
92
+ if settings.nil? || mappings.nil?
93
+ puts "Failed to retrieve settings or mappings for #{index_name}"
94
+ exit 1
95
+ end
96
+
97
+ # Filter out internal settings
98
+ filtered_settings = SettingsFilter.filter_internal_settings(settings)
99
+
100
+ schema_path = File.join(Config.schemas_path, folder_name)
101
+ FileUtils.mkdir_p(schema_path)
102
+
103
+ settings_file = File.join(schema_path, 'settings.json')
104
+ mappings_file = File.join(schema_path, 'mappings.json')
105
+
106
+ File.write(settings_file, JSON.pretty_generate(filtered_settings))
107
+ File.write(mappings_file, JSON.pretty_generate(mappings))
108
+
109
+ puts "✓ Schema downloaded to #{schema_path}"
110
+ puts " - settings.json"
111
+ puts " - mappings.json"
112
+ end
113
+
114
+ end
@@ -0,0 +1,234 @@
1
+ require 'json'
2
+
3
+ module SchemaTools
4
+ class JsonDiff
5
+ def initialize()
6
+ end
7
+
8
+ # Keys to ignore in diff comparisons (noisy metadata)
9
+ IGNORED_KEYS = [].freeze
10
+
11
+ # Generate a detailed diff between two JSON objects
12
+ # Returns a formatted string showing additions, removals, and modifications
13
+ def generate_diff(old_json, new_json, context: {})
14
+ old_normalized = normalize_json(old_json)
15
+ new_normalized = normalize_json(new_json)
16
+
17
+ # Filter out ignored keys
18
+ old_filtered = filter_ignored_keys(old_normalized)
19
+ new_filtered = filter_ignored_keys(new_normalized)
20
+
21
+ if old_filtered == new_filtered
22
+ return "No changes detected"
23
+ end
24
+
25
+ diff_lines = []
26
+ diff_lines << "=== Changes Detected ==="
27
+ diff_lines << ""
28
+
29
+ # Generate detailed diff
30
+ changes = compare_objects(old_filtered, new_filtered, "")
31
+
32
+ if changes.empty?
33
+ diff_lines << "No changes detected"
34
+ else
35
+ changes.each { |change| diff_lines << change }
36
+ end
37
+
38
+ diff_lines.join("\n")
39
+ end
40
+
41
+ private
42
+
43
+ def normalize_json(json_obj)
44
+ return {} unless json_obj
45
+ normalized = JSON.parse(JSON.generate(json_obj))
46
+
47
+ # Normalize OpenSearch/Elasticsearch-specific behavior
48
+ if normalized.is_a?(Hash) && normalized.key?('properties')
49
+ normalized = normalize_mappings(normalized)
50
+ end
51
+
52
+ normalized
53
+ end
54
+
55
+ def normalize_mappings(mappings)
56
+ return mappings unless mappings.is_a?(Hash) && mappings.key?('properties')
57
+
58
+ normalized = mappings.dup
59
+ normalized['properties'] = normalize_properties(mappings['properties'])
60
+ normalized
61
+ end
62
+
63
+ def normalize_properties(properties)
64
+ return properties unless properties.is_a?(Hash)
65
+
66
+ normalized = {}
67
+ properties.each do |key, value|
68
+ if value.is_a?(Hash)
69
+ # Remove implicit "type": "object" if the field has "properties"
70
+ if value.key?('properties') && value['type'] == 'object'
71
+ normalized_value = value.dup
72
+ normalized_value.delete('type')
73
+ normalized[key] = normalize_properties(normalized_value)
74
+ else
75
+ normalized[key] = normalize_properties(value)
76
+ end
77
+ else
78
+ normalized[key] = value
79
+ end
80
+ end
81
+
82
+ normalized
83
+ end
84
+
85
+ def filter_ignored_keys(obj, path_prefix = "")
86
+ return obj unless obj.is_a?(Hash)
87
+
88
+ filtered = {}
89
+ obj.each do |key, value|
90
+ current_path = path_prefix.empty? ? key : "#{path_prefix}.#{key}"
91
+
92
+ # Skip ignored keys
93
+ next if IGNORED_KEYS.any? { |ignored_key| current_path == ignored_key }
94
+
95
+ # Recursively filter nested objects
96
+ if value.is_a?(Hash)
97
+ filtered[key] = filter_ignored_keys(value, current_path)
98
+ else
99
+ filtered[key] = value
100
+ end
101
+ end
102
+
103
+ filtered
104
+ end
105
+
106
+ def compare_objects(old_obj, new_obj, path_prefix)
107
+ changes = []
108
+
109
+ # Handle different object types
110
+ if old_obj.is_a?(Hash) && new_obj.is_a?(Hash)
111
+ changes.concat(compare_hashes(old_obj, new_obj, path_prefix))
112
+ elsif old_obj.is_a?(Array) && new_obj.is_a?(Array)
113
+ changes.concat(compare_arrays(old_obj, new_obj, path_prefix))
114
+ elsif old_obj != new_obj
115
+ changes << format_change(path_prefix, old_obj, new_obj)
116
+ end
117
+
118
+ changes
119
+ end
120
+
121
+ def compare_hashes(old_hash, new_hash, path_prefix)
122
+ changes = []
123
+ all_keys = (old_hash.keys + new_hash.keys).uniq.sort
124
+
125
+ all_keys.each do |key|
126
+ current_path = path_prefix.empty? ? key : "#{path_prefix}.#{key}"
127
+
128
+ # Skip ignored keys
129
+ next if IGNORED_KEYS.any? { |ignored_key| current_path == ignored_key }
130
+
131
+ old_value = old_hash[key]
132
+ new_value = new_hash[key]
133
+
134
+ if old_value.nil? && !new_value.nil?
135
+ changes << "➕ ADDED: #{current_path}"
136
+ changes.concat(format_value_details("New value", new_value, " "))
137
+ elsif !old_value.nil? && new_value.nil?
138
+ changes << "➖ REMOVED: #{current_path}"
139
+ changes.concat(format_value_details("Old value", old_value, " "))
140
+ elsif old_value != new_value
141
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash) ||
142
+ old_value.is_a?(Array) && new_value.is_a?(Array)
143
+ changes.concat(compare_objects(old_value, new_value, current_path))
144
+ else
145
+ changes << "🔄 MODIFIED: #{current_path}"
146
+ changes.concat(format_value_details("Old value", old_value, " "))
147
+ changes.concat(format_value_details("New value", new_value, " "))
148
+ end
149
+ end
150
+ end
151
+
152
+ changes
153
+ end
154
+
155
+ def compare_arrays(old_array, new_array, path_prefix)
156
+ changes = []
157
+
158
+ if old_array.length != new_array.length
159
+ changes << "ARRAY LENGTH CHANGED: #{path_prefix} (#{old_array.length} → #{new_array.length})"
160
+ end
161
+
162
+ # Compare elements up to the minimum length
163
+ min_length = [old_array.length, new_array.length].min
164
+ (0...min_length).each do |index|
165
+ current_path = "#{path_prefix}[#{index}]"
166
+ old_value = old_array[index]
167
+ new_value = new_array[index]
168
+
169
+ if old_value != new_value
170
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash) ||
171
+ old_value.is_a?(Array) && new_value.is_a?(Array)
172
+ changes.concat(compare_objects(old_value, new_value, current_path))
173
+ else
174
+ changes << "🔄 MODIFIED: #{current_path}"
175
+ changes.concat(format_value_details("Old value", old_value, " "))
176
+ changes.concat(format_value_details("New value", new_value, " "))
177
+ end
178
+ end
179
+ end
180
+
181
+ # Handle added elements
182
+ if new_array.length > old_array.length
183
+ (old_array.length...new_array.length).each do |index|
184
+ current_path = "#{path_prefix}[#{index}]"
185
+ changes << "➕ ADDED: #{current_path}"
186
+ changes.concat(format_value_details("New value", new_array[index], " "))
187
+ end
188
+ end
189
+
190
+ # Handle removed elements
191
+ if old_array.length > new_array.length
192
+ (new_array.length...old_array.length).each do |index|
193
+ current_path = "#{path_prefix}[#{index}]"
194
+ changes << "➖ REMOVED: #{current_path}"
195
+ changes.concat(format_value_details("Old value", old_array[index], " "))
196
+ end
197
+ end
198
+
199
+ changes
200
+ end
201
+
202
+ def format_change(path, old_value, new_value)
203
+ "🔄 MODIFIED: #{path}"
204
+ end
205
+
206
+ def format_value_details(label, value, indent)
207
+ details = []
208
+ details << "#{indent}#{label}:"
209
+
210
+ if value.is_a?(String)
211
+ # Handle multiline strings
212
+ if value.include?("\n")
213
+ details << "#{indent} \"\"\""
214
+ value.split("\n").each do |line|
215
+ details << "#{indent} #{line}"
216
+ end
217
+ details << "#{indent} \"\"\""
218
+ else
219
+ details << "#{indent} \"#{value}\""
220
+ end
221
+ elsif value.is_a?(Hash) || value.is_a?(Array)
222
+ # Format complex objects with proper indentation
223
+ formatted = JSON.pretty_generate(value)
224
+ formatted.split("\n").each do |line|
225
+ details << "#{indent} #{line}"
226
+ end
227
+ else
228
+ details << "#{indent} #{value.inspect}"
229
+ end
230
+
231
+ details
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,90 @@
1
+ require_relative '../schema_files'
2
+ require_relative 'migrate_breaking_change'
3
+ require_relative 'migrate_non_breaking_change'
4
+ require_relative 'migrate_new'
5
+ require_relative '../diff'
6
+ require_relative '../settings_diff'
7
+ require_relative '../api_aware_mappings_diff'
8
+ require 'json'
9
+
10
+ module SchemaTools
11
+ def self.migrate_all(client:)
12
+ puts "Discovering all schemas and migrating each to their latest revisions..."
13
+
14
+ schemas = SchemaFiles.discover_all_schemas
15
+
16
+ if schemas.empty?
17
+ puts "No schemas found in #{Config.schemas_path}"
18
+ return
19
+ end
20
+
21
+ puts "Found #{schemas.length} schema(s) to migrate:"
22
+ schemas.each do |schema|
23
+ puts " - #{schema}"
24
+ end
25
+ puts
26
+
27
+ schemas.each do |alias_name|
28
+ begin
29
+ migrate_one_schema(alias_name: alias_name, client: client)
30
+ rescue => e
31
+ puts "✗ Migration failed for #{alias_name}: #{e.message}"
32
+ raise e
33
+ end
34
+ puts
35
+ end
36
+ end
37
+
38
+ def self.migrate_one_schema(alias_name:, client:)
39
+ puts "=" * 60
40
+ puts "Migrating alias #{alias_name}"
41
+ puts "=" * 60
42
+
43
+ schema_path = File.join(Config.schemas_path, alias_name)
44
+ unless Dir.exist?(schema_path)
45
+ raise "Schema folder not found: #{schema_path}"
46
+ end
47
+
48
+ # Check if it's an index name (not an alias)
49
+ if !client.alias_exists?(alias_name) && client.index_exists?(alias_name)
50
+ puts "ERROR: Migration not run for index \"#{alias_name}\""
51
+ puts " To prevent downtime, this tool only migrates aliased indexes."
52
+ puts ""
53
+ puts " Create a new alias for your index by running:"
54
+ puts " rake schema:alias"
55
+ puts ""
56
+ puts " Then rename the schema folder to the alias name and re-run:"
57
+ puts " rake schema:migrate"
58
+ puts ""
59
+ puts " Then change your application to read and write to the alias name instead of the index name `#{alias_name}`."
60
+ raise "Migration not run for alias #{alias_name} because #{alias_name} is an index, not an alias"
61
+ end
62
+
63
+ unless client.alias_exists?(alias_name)
64
+ puts "Alias '#{alias_name}' not found. Creating new index and alias..."
65
+ migrate_to_new_alias(alias_name, client)
66
+ return
67
+ end
68
+
69
+ indices = client.get_alias_indices(alias_name)
70
+ if indices.length > 1
71
+ puts "This tool can only migrate aliases that point at one index."
72
+ raise "Alias '#{alias_name}' points to multiple indices: #{indices.join(', ')}"
73
+ end
74
+
75
+ if indices.length == 0
76
+ raise "Alias '#{alias_name}' points to no indices."
77
+ end
78
+
79
+ index_name = indices.first
80
+ puts "Alias '#{alias_name}' points to index '#{index_name}'"
81
+ begin
82
+ attempt_non_breaking_migration(alias_name:, index_name:, client:)
83
+ rescue => e
84
+ puts "✗ Failed to update index '#{index_name}': #{e.message}"
85
+ puts "This appears to be a breaking change. Starting breaking change migration..."
86
+
87
+ MigrateBreakingChange.migrate(alias_name:, client:)
88
+ end
89
+ end
90
+ end