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,373 @@
1
+ require_relative '../schema_files'
2
+ require_relative 'migration_step'
3
+ require_relative 'migrate_verify'
4
+ require_relative '../diff'
5
+ require_relative 'rollback'
6
+ require 'json'
7
+
8
+ module SchemaTools
9
+ # Custom logger that uses the migration's log() method
10
+ class MigrationLogger
11
+ attr_writer :migration_log_index
12
+
13
+ def initialize(migration_log_index, client)
14
+ @migration_log_index = migration_log_index
15
+ @client = client
16
+ end
17
+
18
+ def info(message)
19
+ log(message)
20
+ end
21
+
22
+ def warn(message)
23
+ log(message)
24
+ end
25
+
26
+ def error(message)
27
+ log(message)
28
+ end
29
+
30
+ def log(message)
31
+ puts message
32
+ log_to_log_index(message)
33
+ end
34
+
35
+ def log_to_log_index(message)
36
+ return unless @migration_log_index
37
+ doc = {
38
+ timestamp: Time.now.iso8601,
39
+ message: message.is_a?(String) ? message : message.to_json
40
+ }
41
+ @client.post("/#{@migration_log_index}/_doc", doc, suppress_logging: true)
42
+ end
43
+ end
44
+
45
+ class MigrateBreakingChange
46
+ def self.migrate(alias_name:, client:)
47
+ new(alias_name: alias_name, client: client).migrate
48
+ end
49
+
50
+ def initialize(alias_name:, client:)
51
+ @alias_name = alias_name
52
+ @client = client
53
+ @migration_log_index = nil
54
+ @current_step = nil
55
+ @rollback_attempted = false
56
+
57
+ @logger = MigrationLogger.new(nil, client)
58
+ @client.instance_variable_set(:@logger, @logger)
59
+ end
60
+
61
+ def migrate
62
+ log "=" * 60
63
+ log "Breaking Change Migration for #{@alias_name}"
64
+ log "=" * 60
65
+
66
+ begin
67
+ setup
68
+ migration_steps.each do |step|
69
+ @current_step = step
70
+ step.execute(self)
71
+ end
72
+ SchemaTools.verify_migration(@alias_name, @client)
73
+ rescue => e
74
+ log("Migration failed: #{e.message}")
75
+ raise e
76
+ end
77
+ end
78
+
79
+ def setup
80
+ unless @client.alias_exists?(@alias_name)
81
+ raise "Alias '#{@alias_name}' does not exist"
82
+ end
83
+
84
+ indices = @client.get_alias_indices(@alias_name)
85
+ if indices.length != 1
86
+ log "ERROR: Alias '#{@alias_name}' must point to exactly one index"
87
+ log " Currently points to: #{indices.join(', ')}"
88
+ raise "Alias '#{@alias_name}' must point to exactly one index"
89
+ end
90
+
91
+ @new_timestamp = Time.now.strftime('%Y%m%d%H%M%S')
92
+ @migration_log_index = "#{@alias_name}-#{@new_timestamp}-migration-log"
93
+ log "Creating log index: #{@migration_log_index}"
94
+ @client.create_index(@migration_log_index, {}, {})
95
+ @logger.migration_log_index = @migration_log_index
96
+ log "Logging to '#{@migration_log_index}'"
97
+
98
+ @current_index = indices.first
99
+ log "Alias '#{@alias_name}' points to index '#{@current_index}'"
100
+
101
+ @new_index = "#{@alias_name}-#{@new_timestamp}"
102
+ log "new_index: #{@new_index}"
103
+
104
+ @catchup1_index = "#{@new_index}-catchup-1"
105
+ log "catchup1_index: #{@catchup1_index}"
106
+
107
+ @catchup2_index = "#{@new_index}-catchup-2"
108
+ log "catchup2_index: #{@catchup2_index}"
109
+
110
+ # Use current index settings and mappings when creating catchup indexes
111
+ # so that any reindex painless script logic will apply correctly to them.
112
+ @current_settings = @client.get_index_settings(@current_index)
113
+ @current_mappings = @client.get_index_mappings(@current_index)
114
+ raise "Schema files not found for #{@current_index}" unless @current_settings && @current_mappings
115
+ # Filter read-only settings
116
+ @current_settings = SettingsFilter.filter_internal_settings(@current_settings)
117
+ log "Current settings: #{JSON.generate(@current_settings)}"
118
+ log "Current mappings: #{JSON.generate(@current_mappings)}"
119
+
120
+ @new_settings = SchemaFiles.get_settings(@alias_name)
121
+ @new_mappings = SchemaFiles.get_mappings(@alias_name)
122
+ raise "Schema files not found for #{@alias_name}" unless @new_settings && @new_mappings
123
+ log "New settings: #{JSON.generate(@new_settings)}"
124
+ log "New mappings: #{JSON.generate(@new_mappings)}"
125
+
126
+ @reindex_script = SchemaFiles.get_reindex_script(@alias_name)
127
+ if @reindex_script
128
+ log "Using reindex painless script defined for #{@alias_name}"
129
+ log "reindex.painless script: #{@reindex_script}"
130
+ end
131
+ end
132
+
133
+ def log(message)
134
+ @logger.info(message)
135
+ end
136
+
137
+ def migration_steps
138
+ [
139
+ MigrationStep.new(
140
+ name: "STEP 1: Create catchup-1 index",
141
+ run: ->(logger) { step1_create_catchup1 }
142
+ ),
143
+ MigrationStep.new(
144
+ name: "STEP 2: Configure alias for write to catchup-1",
145
+ run: ->(logger) { step2_configure_alias_write_catchup1_read_both }
146
+ ),
147
+ MigrationStep.new(
148
+ name: "STEP 3: Reindex to new index",
149
+ run: ->(logger) { step3_reindex_to_new_index }
150
+ ),
151
+ MigrationStep.new(
152
+ name: "STEP 4: Create catchup-2 index",
153
+ run: ->(logger) { step4_create_catchup2 }
154
+ ),
155
+ MigrationStep.new(
156
+ name: "STEP 5: Configure alias for write to catchup-2",
157
+ run: ->(logger) { step5_configure_alias_write_catchup2_read_all }
158
+ ),
159
+ MigrationStep.new(
160
+ name: "STEP 6: Merge catchup-1 to new index",
161
+ run: ->(logger) { step6_merge_catchup1_to_new }
162
+ ),
163
+ MigrationStep.new(
164
+ name: "STEP 7: Configure alias with no write indexes",
165
+ run: ->(logger) { step7_configure_alias_no_write }
166
+ ),
167
+ MigrationStep.new(
168
+ name: "STEP 8: Merge catchup-2 to new index",
169
+ run: ->(logger) { step8_merge_catchup2_to_new }
170
+ ),
171
+ MigrationStep.new(
172
+ name: "STEP 9: Configure alias to new index only",
173
+ run: ->(logger) { step9_configure_alias_final }
174
+ ),
175
+ MigrationStep.new(
176
+ name: "STEP 10: Close unused indexes",
177
+ run: ->(logger) { step10_close_unused_indexes }
178
+ )
179
+ ]
180
+ end
181
+
182
+ def step1_create_catchup1
183
+ @client.create_index(@catchup1_index, @current_settings, @current_mappings)
184
+ log "Created catchup-1 index: #{@catchup1_index}"
185
+ end
186
+
187
+ def step2_configure_alias_write_catchup1_read_both
188
+ actions = [
189
+ {
190
+ add: {
191
+ index: @catchup1_index,
192
+ alias: @alias_name,
193
+ is_write_index: true
194
+ }
195
+ },
196
+ {
197
+ add: {
198
+ index: @current_index,
199
+ alias: @alias_name,
200
+ is_write_index: false
201
+ }
202
+ }
203
+ ]
204
+ update_aliases(actions)
205
+ log "Configured alias #{@alias_name} to write to #{@catchup1_index} and read from both indexes"
206
+ end
207
+
208
+ def update_aliases(actions)
209
+ response = @client.update_aliases(actions)
210
+ if response['errors']
211
+ log "ERROR: Failed to update aliases"
212
+ log actions
213
+ log response
214
+ raise "Failed to update aliases"
215
+ end
216
+ end
217
+
218
+ def step3_reindex_to_new_index
219
+ @client.create_index(@new_index, @new_settings, @new_mappings)
220
+ begin
221
+ reindex(@current_index, @new_index, @reindex_script)
222
+ rescue => e
223
+ attempt_rollback(e)
224
+ raise e # Re-raise the error after rollback
225
+ end
226
+ end
227
+
228
+ def reindex(current_index, new_index, reindex_script)
229
+ response = @client.reindex(current_index, new_index, reindex_script)
230
+ log response
231
+
232
+ if response['took']
233
+ log "Reindex task complete. Took: #{response['took']}"
234
+ return true
235
+ end
236
+
237
+ task_id = response['task']
238
+ if !task_id
239
+ raise "No task ID from reindex. Reindex incomplete."
240
+ end
241
+
242
+ log "Reindex task started at #{Time.now}. task_id is #{task_id}. Fetch task status with GET #{@client.url}/_tasks/#{task_id}"
243
+
244
+ timeout = 604800 # 1 week
245
+ @client.wait_for_task(response['task'], timeout)
246
+ log "Reindex complete"
247
+ end
248
+
249
+ def step4_create_catchup2
250
+ @client.create_index(@catchup2_index, @current_settings, @current_mappings)
251
+ log "Created catchup-2 index: #{@catchup2_index}"
252
+ end
253
+
254
+ def step5_configure_alias_write_catchup2_read_all
255
+ actions = [
256
+ # keep reading from current_index and catchup1_index
257
+ # add a new catchup2_index for writes
258
+ {
259
+ add: {
260
+ index: @catchup2_index,
261
+ alias: @alias_name,
262
+ is_write_index: true
263
+ }
264
+ },
265
+ {
266
+ add: {
267
+ index: @catchup1_index,
268
+ alias: @alias_name,
269
+ is_write_index: false
270
+ }
271
+ },
272
+ {
273
+ add: {
274
+ index: @current_index,
275
+ alias: @alias_name,
276
+ is_write_index: false
277
+ }
278
+ }
279
+ ]
280
+ update_aliases(actions)
281
+ log "Configured alias #{@alias_name} to write to #{@catchup2_index} and continue reading from current and catchup1 indexes"
282
+ end
283
+
284
+ def step6_merge_catchup1_to_new
285
+ reindex(@catchup1_index, @new_index, @reindex_script)
286
+ log "Catchup-1 merged to new index"
287
+ end
288
+
289
+ def step7_configure_alias_no_write
290
+ actions = [
291
+ {
292
+ add: {
293
+ index: @catchup2_index,
294
+ alias: @alias_name,
295
+ is_write_index: false
296
+ }
297
+ },
298
+ {
299
+ add: {
300
+ index: @catchup1_index,
301
+ alias: @alias_name,
302
+ is_write_index: false
303
+ }
304
+ },
305
+ {
306
+ add: {
307
+ index: @current_index,
308
+ alias: @alias_name,
309
+ is_write_index: false
310
+ }
311
+ }
312
+ ]
313
+ update_aliases(actions)
314
+ log "Configured alias #{@alias_name} with NO write indexes - writes will fail temporarily"
315
+ end
316
+
317
+ def step8_merge_catchup2_to_new
318
+ reindex_script = SchemaFiles.get_reindex_script(@alias_name)
319
+ reindex(@catchup2_index, @new_index, reindex_script)
320
+ end
321
+
322
+ def step9_configure_alias_final
323
+ actions = [
324
+ {
325
+ remove: {
326
+ index: @catchup2_index,
327
+ alias: @alias_name
328
+ }
329
+ },
330
+ {
331
+ remove: {
332
+ index: @catchup1_index,
333
+ alias: @alias_name
334
+ }
335
+ },
336
+ {
337
+ remove: {
338
+ index: @current_index,
339
+ alias: @alias_name
340
+ }
341
+ },
342
+ {
343
+ add: {
344
+ index: @new_index,
345
+ alias: @alias_name,
346
+ is_write_index: true
347
+ }
348
+ }
349
+ ]
350
+ update_aliases(actions)
351
+ log "Configured alias #{@alias_name} to write and read from #{@new_index} only"
352
+ end
353
+
354
+ def step10_close_unused_indexes
355
+ [@current_index, @catchup1_index, @catchup2_index, @migration_log_index].each do |index|
356
+ if @client.index_exists?(index)
357
+ log "Closing index: #{index}"
358
+ @client.close_index(index)
359
+ end
360
+ end
361
+ @migration_log_index = nil
362
+ @logger.migration_log_index = nil
363
+ end
364
+
365
+ private
366
+
367
+ def attempt_rollback(original_error)
368
+ rollback = Migrate::Rollback.new(@alias_name, @current_index, @catchup1_index, @new_index, @client, self)
369
+ rollback.attempt_rollback(original_error)
370
+ end
371
+
372
+ end
373
+ end
@@ -0,0 +1,33 @@
1
+ require_relative '../schema_files'
2
+ require_relative 'migrate_breaking_change'
3
+ require_relative '../diff'
4
+ require_relative '../settings_diff'
5
+ require_relative '../api_aware_mappings_diff'
6
+ require 'json'
7
+
8
+ module SchemaTools
9
+ def self.migrate_to_new_alias(alias_name, client)
10
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
11
+ new_index_name = "#{alias_name}-#{timestamp}"
12
+
13
+ settings = SchemaFiles.get_settings(alias_name)
14
+ mappings = SchemaFiles.get_mappings(alias_name)
15
+
16
+ if settings.nil? || mappings.nil?
17
+ schema_path = File.join(Config.schemas_path, alias_name)
18
+ puts "ERROR: Could not load schema files for #{alias_name}"
19
+ puts " Make sure settings.json and mappings.json exist in #{schema_path}"
20
+ raise "Could not load schema files for #{alias_name}"
21
+ end
22
+
23
+ puts "Creating new index '#{new_index_name}' with provided schema..."
24
+ client.create_index(new_index_name, settings, mappings)
25
+ puts "✓ Index '#{new_index_name}' created"
26
+
27
+ puts "Creating alias '#{alias_name}' pointing to '#{new_index_name}'..."
28
+ client.create_alias(alias_name, new_index_name)
29
+ puts "✓ Alias '#{alias_name}' created and configured"
30
+
31
+ puts "Migration completed successfully!"
32
+ end
33
+ end
@@ -0,0 +1,74 @@
1
+ require_relative '../schema_files'
2
+ require_relative 'migrate_verify'
3
+ require_relative '../diff'
4
+ require_relative '../settings_diff'
5
+ require_relative '../api_aware_mappings_diff'
6
+ require 'json'
7
+
8
+ module SchemaTools
9
+ def self.attempt_non_breaking_migration(alias_name:, index_name:, client:)
10
+ settings = SchemaFiles.get_settings(alias_name)
11
+ mappings = SchemaFiles.get_mappings(alias_name)
12
+
13
+ if settings.nil? || mappings.nil?
14
+ schema_path = File.join(Config.schemas_path, alias_name)
15
+ puts "ERROR: Could not load schema files for #{alias_name}"
16
+ puts " Make sure settings.json and mappings.json exist in #{schema_path}"
17
+ raise "Could not load schema files for #{alias_name}"
18
+ end
19
+
20
+ puts "Checking for differences between local schema and live alias..."
21
+ diff_result = Diff.generate_schema_diff(alias_name, client)
22
+
23
+ if diff_result[:status] == :no_changes
24
+ puts "✓ No differences detected between local schema and live alias"
25
+ puts "✓ Migration skipped - index is already up to date"
26
+ return
27
+ end
28
+
29
+ puts "Showing diff between local schema and live alias before migration:"
30
+ puts "-" * 60
31
+ Diff.print_schema_diff(diff_result)
32
+ puts "-" * 60
33
+
34
+ puts "Attempting to update index '#{index_name}' in place with new schema as a non-breaking change..."
35
+ begin
36
+ remote_settings = client.get_index_settings(index_name)
37
+ filtered_remote_settings = SettingsFilter.filter_internal_settings(remote_settings)
38
+
39
+ settings_diff = SettingsDiff.new(settings, filtered_remote_settings)
40
+ minimal_settings_changes = settings_diff.generate_minimal_changes
41
+
42
+ if minimal_settings_changes.empty?
43
+ puts "✓ No settings changes needed - settings are already up to date"
44
+ else
45
+ puts "Applying minimal settings changes"
46
+ client.update_index_settings(index_name, minimal_settings_changes)
47
+ puts "✓ Settings updated successfully"
48
+ end
49
+
50
+ remote_mappings = client.get_index_mappings(index_name)
51
+ mappings_diff = ApiAwareMappingsDiff.new(mappings, remote_mappings)
52
+ minimal_mappings_changes = mappings_diff.generate_minimal_changes
53
+
54
+ if minimal_mappings_changes.empty?
55
+ puts "✓ No mappings changes needed - mappings are already up to date"
56
+ else
57
+ puts "Applying minimal mappings changes"
58
+ client.update_index_mappings(index_name, minimal_mappings_changes)
59
+ puts "✓ Mappings updated successfully"
60
+ end
61
+
62
+ puts "✓ Index '#{index_name}' updated successfully"
63
+
64
+ SchemaTools.verify_migration(alias_name, client)
65
+ rescue => e
66
+ if e.message.include?("no settings to update")
67
+ puts "✓ No settings changes needed - index is already up to date"
68
+ puts "Migration completed successfully!"
69
+ else
70
+ raise e
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,19 @@
1
+ require_relative '../diff'
2
+
3
+ module SchemaTools
4
+ def self.verify_migration(alias_name, client)
5
+ puts "Verifying migration by comparing local schema with remote index..."
6
+ diff_result = Diff.generate_schema_diff(alias_name, client)
7
+
8
+ if diff_result[:status] == :no_changes
9
+ puts "✓ Migration verification successful - no differences detected"
10
+ puts "Migration completed successfully!"
11
+ else
12
+ puts "⚠️ Migration verification failed - differences detected:"
13
+ puts "-" * 60
14
+ Diff.print_schema_diff(diff_result)
15
+ puts "-" * 60
16
+ raise "Migration verification failed - local schema does not match remote index after migration"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ module SchemaTools
2
+ class MigrationStep
3
+ attr_reader :name, :before_actions, :run_actions, :after_actions
4
+
5
+ def initialize(name:, run:)
6
+ @name = name
7
+ @before_actions = []
8
+ @run_actions = [run]
9
+ @after_actions = []
10
+ add_default_logging
11
+ end
12
+
13
+ def add_before(action)
14
+ @before_actions << action
15
+ self
16
+ end
17
+
18
+ def add_after(action)
19
+ @after_actions << action
20
+ self
21
+ end
22
+
23
+ def execute(logger)
24
+ @before_actions.each { |action| action.call(logger) }
25
+ @run_actions.each { |action| action.call(logger) }
26
+ @after_actions.each { |action| action.call(logger) }
27
+ end
28
+
29
+ private
30
+
31
+ def add_default_logging
32
+ @before_actions << ->(logger) { logger.log("\nSTARTING: #{@name}") }
33
+ @after_actions << ->(logger) { logger.log("COMPLETED: #{@name}") }
34
+ end
35
+ end
36
+ end