cauchy 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: acfe1f9c387508642988f41f47e65b0ea809b6c5
4
+ data.tar.gz: 4b3868c009a60a3217ea04589876537ee73dfee2
5
+ SHA512:
6
+ metadata.gz: dada688d444f5079b1a7a273f9d2a732d99cc5d61f1c2359edd3864fa3139c7f2a80cafb3091e9f6b0056d9cb5f4381e1d7973cd054aa3627ef6f4599903f744
7
+ data.tar.gz: 1def7a74c1ccc400dcbeedbda077538a88b75f8de90983718390e27d93a565e0e00fc27e84f4b86c58cdfed76682d4af8f38f3b788e46a3eb5b8fc5629302920
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ .bundle
12
+ vendor/
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ cache: bundler
3
+ rvm:
4
+ - 2.2.5
5
+ services:
6
+ - elasticsearch
7
+ env:
8
+ global:
9
+ - "ELASTICSEARCH_HOSTS=http://localhost:9200,http://127.0.0.1:9200"
10
+ script: bundle exec rake spec
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Zinc Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Cauchy
2
+
3
+ [![Build Status](https://api.travis-ci.com/cotap/cauchy.svg?token=PQCDXv39wYQSK1Gnq72A&branch=master)](https://magnum.travis-ci.com/cotap/cauchy)
4
+
5
+ Cauchy manages your ES Index Schemas.
6
+
7
+ ## Installation
8
+
9
+ Install the Cauchy gem globally with
10
+
11
+ $ gem install cauchy
12
+
13
+ And verify your install with
14
+
15
+ $ cauchy version
16
+
17
+ ## Usage
18
+
19
+ ### Setup
20
+
21
+ To get started, create a new Cauchy project structure
22
+
23
+ $ cauchy init --path=/some/place/special
24
+
25
+ This will create the following structure
26
+
27
+ ```
28
+ ├── config.yml
29
+ └── schema
30
+ ```
31
+
32
+ Configure Cauchy's `config.yml` with your elasticsearch settings. You can find more information about the available options at the [ElasticSearch Transport docs](http://www.rubydoc.info/gems/elasticsearch-transport).
33
+
34
+ ### Creating an Index Schema
35
+
36
+ To create a new index schema, simply
37
+
38
+ $ cauchy new [index_schema_name]
39
+
40
+ This will generate a new template schema file located in your `schemas/` directory.
41
+
42
+ ### Applying your schema
43
+
44
+ Once you've configuring your schema, you're ready to apply it to ES with
45
+
46
+ $ cauchy apply [index_schema_name]
47
+
48
+ __Note:__ the schema name is optional here, and if left blank, Cauchy will run against all your defined schemas
49
+
50
+ ## Development
51
+
52
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
53
+
54
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
55
+
56
+ ## Contributing
57
+
58
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cotap/cauchy.
59
+
60
+ ## License
61
+
62
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
63
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
data/bin/cauchy ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'cauchy/cli'
5
+
6
+ trap('INT') { puts ''; exit }
7
+
8
+ Cauchy::Cli.start ARGV
data/cauchy.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'cauchy/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'cauchy'
8
+ s.version = Cauchy::VERSION
9
+ s.authors = ['Paul Hamera', 'Evan Owen']
10
+ s.email = ['paul@zinc.it', 'evan@zinc.it']
11
+
12
+ s.summary = 'An elasticsearch schema management tool'
13
+ s.description = 'An elasticsearch schema management tool'
14
+ s.homepage = 'https://github.com/cotap'
15
+ s.license = 'MIT'
16
+
17
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ s.executables = Dir['bin/*'].map{ |f| File.basename(f) }
19
+ s.require_paths = ['lib']
20
+
21
+ s.required_ruby_version = '>= 2.0.0'
22
+
23
+ s.add_dependency 'activesupport', '>= 4.2'
24
+ s.add_dependency 'diffy', '~> 3.1'
25
+ s.add_dependency 'elasticsearch', '~> 1.0'
26
+ s.add_dependency 'indentation', '~> 0.0'
27
+ s.add_dependency 'json', '>= 1.8'
28
+ s.add_dependency 'rainbow', '~> 2.1'
29
+ s.add_dependency 'ruby-progressbar', '~> 1.8'
30
+ s.add_dependency 'thor', '~> 0.18'
31
+
32
+ s.add_development_dependency 'bundler', '~> 1.11'
33
+ s.add_development_dependency 'rake', '~> 10.0'
34
+ s.add_development_dependency 'rspec', '~> 3.0'
35
+ s.add_development_dependency 'rspec-its', '~> 1.2'
36
+ s.add_development_dependency 'pry'
37
+ end
data/lib/cauchy/cli.rb ADDED
@@ -0,0 +1,158 @@
1
+ require 'active_support/core_ext/hash/slice'
2
+ require 'active_support/core_ext/string/inflections'
3
+ require 'active_support/hash_with_indifferent_access'
4
+ require 'thor'
5
+ require 'yaml'
6
+
7
+ require 'cauchy'
8
+
9
+ module Cauchy
10
+
11
+ class Cli < Thor
12
+ include Thor::Actions
13
+
14
+ default_path = ENV.fetch('CAUCHY_PATH', '.')
15
+
16
+ class_option :path, default: default_path,
17
+ banner: 'project path', desc: 'CAUCHY_PATH env var will be recognized'
18
+
19
+ desc 'init', 'Create cauchy project directories'
20
+ def init
21
+ init_project
22
+ end
23
+
24
+ desc 'new [SCHEMA_NAME]', 'Create an empty index schema file'
25
+ def new(name)
26
+ verify_project!
27
+ generate_schema name
28
+ end
29
+
30
+ desc 'apply [SCHEMA_NAME]', 'Applys a schema'
31
+ method_option :reindex, default: false, type: :boolean, desc: 'Reindex data, if required'
32
+ method_option :close_index, default: false, type: :boolean, desc: 'Close index to update non-dynamic settings'
33
+ def apply(schema_name = nil)
34
+ verify_project!
35
+ Migrator.migrate(
36
+ client, schema_path, schema_name,
37
+ options.slice('reindex', 'close_index').symbolize_keys
38
+ )
39
+ rescue Elastic::CannotUpdateNonDynamicSettingsError => e
40
+ Cauchy.logger.warn e.to_s
41
+ Cauchy.logger.warn 'Provide --close-index in order to perform this update'
42
+ rescue MigrationError => e
43
+ Cauchy.logger.warn e.to_s
44
+ end
45
+
46
+ desc 'status [SCHEMA_NAME]', 'Displays index status'
47
+ def status(schema_name = nil)
48
+ verify_project!
49
+ Migrator.status(client, schema_path, schema_name)
50
+ rescue MigrationError => e
51
+ Cauchy.logger.warn e.to_s
52
+ end
53
+
54
+ desc 'version', 'Displays the version number'
55
+ def version
56
+ Cauchy.logger.info "Couchy v#{Cauchy::VERSION}"
57
+ end
58
+
59
+ private
60
+
61
+ def project_path
62
+ @project_path ||= File.expand_path options['path']
63
+ end
64
+
65
+ def config_path
66
+ @config_path ||= File.join project_path, 'config.yml'
67
+ end
68
+
69
+ def schema_path(schema_file = nil)
70
+ File.join *[project_path, 'schema', schema_file].compact
71
+ end
72
+
73
+ def client
74
+ @client ||= Elastic::Client.new(config)
75
+ end
76
+
77
+ def config
78
+ @config ||= begin
79
+ if File.exists?(config_path)
80
+ YAML.load_file(config_path).deep_symbolize_keys
81
+ else
82
+ { url: ENV.fetch('ELASTICSEARCH_URL', 'localhost:9200') }
83
+ end
84
+ end
85
+ end
86
+
87
+ def yaml_config
88
+
89
+ end
90
+
91
+ def normalize_schema_name(name)
92
+ name.gsub(/[^a-z0-9\-]+/i, '_').downcase
93
+ end
94
+
95
+ def verify_project!
96
+ unless Dir.exists? schema_path
97
+ Cauchy.logger.fatal "Unable to locate schema directory #{schema_path}"
98
+ Cauchy.logger.info 'Did you setup a project with `cauchy init`?'
99
+ exit
100
+ end
101
+ end
102
+
103
+ def init_project
104
+ unless Dir.exists? schema_path
105
+ Cauchy.logger.debug "Creating schema directory at #{schema_path}"
106
+ FileUtils.mkdir_p schema_path
107
+ end
108
+
109
+ unless File.exists? config_path
110
+ Cauchy.logger.debug "Creating config file at #{config_path}"
111
+ File.open(config_path, 'w+') { |f| f.print <<-YAML }
112
+ # Supports elasticsearch/transport options. See: http://www.rubydoc.info/gems/elasticsearch-transport
113
+ # This file is optional, you can also set ELASTICSEARCH_URL in your environment
114
+ hosts:
115
+ - host: localhost
116
+ port: 9200
117
+ scheme: http
118
+ YAML
119
+ end
120
+
121
+ Cauchy.logger.info "\nWant to run cauchy from anywhere? Add the following to your shell config:"
122
+ Cauchy.logger.debug "export CAUCHY_PATH=#{project_path}"
123
+ end
124
+
125
+ def generate_schema(name)
126
+ name = normalize_schema_name(name)
127
+ path = schema_path("#{name}.rb")
128
+
129
+ if File.exists? path
130
+ return unless yes? "Schema file exists at #{path}, overwrite? (y/n)?"
131
+ end
132
+
133
+ Cauchy.logger.info "Creating a new schema `#{name}` at #{path}"
134
+
135
+ File.open(path, 'w+') { |f| f.print <<-RB }
136
+ Cauchy::IndexSchema.define(:#{name}) do
137
+
138
+ settings do
139
+ # {
140
+ # number_of_replicas: 2
141
+ # }
142
+ end
143
+
144
+ mappings do
145
+ # {
146
+ # #{name.singularize}: {
147
+ # properties: {
148
+ # name: { type: 'string' }
149
+ # }
150
+ # }
151
+ # }
152
+ end
153
+
154
+ end
155
+ RB
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,29 @@
1
+ require 'elasticsearch'
2
+
3
+ require 'cauchy/elastic/index'
4
+
5
+ module Cauchy
6
+ module Elastic
7
+ class Client
8
+
9
+ attr_reader :server
10
+
11
+ def initialize(config)
12
+ @server = Elasticsearch::Client.new(config)
13
+ end
14
+
15
+ def index(name)
16
+ Index.new(server, name)
17
+ end
18
+
19
+ def bulk(body)
20
+ server.bulk body: body
21
+ end
22
+
23
+ def update_aliases(body)
24
+ server.indices.update_aliases body: body
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,115 @@
1
+ module Cauchy
2
+ module Elastic
3
+
4
+ class ElasticError < StandardError
5
+ def initialize(message = nil)
6
+ super
7
+ end
8
+ end
9
+
10
+ class IndexAlreadyExistsError < ElasticError
11
+ def initialize(name)
12
+ super("Index \"#{name}\" already exists")
13
+ end
14
+ end
15
+
16
+ class CannotUpdateNonDynamicSettingsError < ElasticError
17
+ def initialize(name)
18
+ super("Index \"#{name}\" cannot be updated while open.")
19
+ end
20
+ end
21
+
22
+ class Index
23
+
24
+ attr_reader :server, :name
25
+
26
+ def initialize(server, name)
27
+ @server = server
28
+ @name = name
29
+ end
30
+
31
+ def exists?
32
+ server.indices.exists? index: name
33
+ end
34
+
35
+ def aliases
36
+ get 'aliases'
37
+ end
38
+
39
+ def mappings
40
+ get('mapping')[name]['mappings']
41
+ rescue
42
+ {}
43
+ end
44
+
45
+ def settings
46
+ get('settings')[name]['settings']['index']
47
+ rescue
48
+ {}
49
+ end
50
+
51
+ def alias=(alias_name)
52
+ server.indices.put_alias index: name, name: alias_name
53
+ end
54
+
55
+ def mappings=(mappings)
56
+ mappings.each do |type, mapping|
57
+ server.indices.put_mapping index: name, type: type,
58
+ body: { type => mapping }
59
+ end
60
+ end
61
+
62
+ def settings=(settings)
63
+ server.indices.put_settings index: name, body: settings
64
+ rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
65
+ if e.message =~ /update non dynamic settings/
66
+ raise CannotUpdateNonDynamicSettingsError.new(name)
67
+ else
68
+ raise
69
+ end
70
+ end
71
+
72
+ def create(settings: {}, mappings: {})
73
+ server.indices.create index: name,
74
+ body: { settings: settings, mappings: mappings }
75
+ rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
76
+ if e.message =~ /IndexAlreadyExists/
77
+ raise IndexAlreadyExistsError.new(name)
78
+ else
79
+ raise
80
+ end
81
+ end
82
+
83
+ def delete
84
+ server.indices.delete index: name
85
+ end
86
+
87
+ def open
88
+ server.indices.open index: name
89
+ end
90
+
91
+ def close
92
+ server.indices.close index: name
93
+ end
94
+
95
+ def scroll(options = {})
96
+ options = { index: name, search_type: 'scan', scroll: '5m', size: 100 }.merge(options)
97
+ scroll_id = server.search(options)['_scroll_id']
98
+ begin
99
+ results = server.scroll(scroll_id: scroll_id, scroll: options[:scroll])
100
+ documents, scroll_id = results['hits']['hits'], results['_scroll_id']
101
+ yield documents, results['hits']['total'] if documents.any?
102
+ end while documents.size > 0
103
+ end
104
+
105
+ private
106
+
107
+ def get(resource)
108
+ server.indices.send(['get', resource].join('_').to_sym, index: name)
109
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
110
+ nil
111
+ end
112
+
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,56 @@
1
+ require 'active_support/core_ext/hash'
2
+
3
+ module Cauchy
4
+ class IndexSchema
5
+ module Normalization
6
+
7
+ IGNORE_SETTINGS = %w[
8
+ creation_date
9
+ uuid
10
+ version
11
+ ]
12
+
13
+ def normalize_mapping(hash)
14
+ hash.deep_stringify_keys.sort.map do |key, field|
15
+ if field.key?('properties')
16
+ field['properties'] = normalize_mapping(field['properties'])
17
+ field['type'] = 'object'
18
+ end
19
+
20
+ if field['type'] == 'date'
21
+ field['format'] ||= 'dateOptionalTime'
22
+ end
23
+
24
+ if ['boolean', 'long', 'double', 'date'].include?(field['type'])
25
+ field.delete('analyzer')
26
+ field['index'] ||= 'not_analyzed'
27
+ end
28
+
29
+ if key == 'properties'
30
+ field = normalize_mapping(field)
31
+ else
32
+ field = normalize_value(field)
33
+ end
34
+
35
+ [key, field]
36
+ end.to_h
37
+ end
38
+
39
+ def normalize_settings(hash)
40
+ normalize_value(hash.deep_stringify_keys.except(*IGNORE_SETTINGS))
41
+ end
42
+
43
+ def normalize_value(value)
44
+ case value
45
+ when Hash
46
+ value.sort.map {|key, v| [key, normalize_value(v)] }.to_h
47
+ when Numeric
48
+ value.to_s
49
+ else
50
+ value
51
+ end
52
+ end
53
+
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,77 @@
1
+ require 'cauchy/index_schema/normalization'
2
+
3
+ module Cauchy
4
+ class IndexSchema
5
+
6
+ include Normalization
7
+
8
+ @@schemas = {}
9
+
10
+ class << self
11
+
12
+ def define(index_alias, &block)
13
+ index_alias = index_alias.to_s
14
+ schemas[index_alias] = new(index_alias).tap { |s| s.define(&block) }
15
+ end
16
+
17
+ def load_schemas(paths)
18
+ Dir[*Array(paths).map { |p| "#{p}/**/*.rb" }].each { |f| load f }
19
+ end
20
+
21
+ def schemas
22
+ @@schemas
23
+ end
24
+
25
+ def schemas=(schemas)
26
+ @@schemas = schemas
27
+ end
28
+
29
+ end
30
+
31
+ attr_accessor :index_alias
32
+
33
+ def initialize(index_alias)
34
+ @index_alias = index_alias
35
+ end
36
+
37
+ def define(&block)
38
+ instance_eval(&block)
39
+ end
40
+
41
+ def mappings(&block)
42
+ self.mappings = block.call || {} if block_given?
43
+ @mappings || {}
44
+ end
45
+
46
+ def mappings=(value)
47
+ @mappings = value.map do |type, mapping|
48
+ [type.to_s, normalize_mapping(mapping)]
49
+ end.to_h
50
+ end
51
+
52
+ def mapping_for(type)
53
+ return unless mappings.key?(type)
54
+ mappings[type]['properties']
55
+ end
56
+
57
+ def settings(&block)
58
+ self.settings = block.call || {} if block_given?
59
+ @settings || {}
60
+ end
61
+
62
+ def settings=(value)
63
+ @settings = normalize_settings(value)
64
+ end
65
+
66
+ def types
67
+ mappings.keys
68
+ end
69
+
70
+ def version
71
+ @version ||= Digest::SHA1.hexdigest(
72
+ { settings: settings, mappings: mappings }.to_json
73
+ )
74
+ end
75
+
76
+ end
77
+ end
@@ -0,0 +1,58 @@
1
+ require 'delegate'
2
+ require 'indentation'
3
+ require 'rainbow/ext/string'
4
+ require 'logger'
5
+
6
+ module Cauchy
7
+ module_function
8
+
9
+ class << self
10
+
11
+ class PrettyLogger < SimpleDelegator
12
+ def initialize logger
13
+ super
14
+
15
+ old_formatter = logger.formatter
16
+
17
+ logger.formatter = proc do |level, time, prog, msg|
18
+ unless msg.start_with?("\e")
19
+ color = case level
20
+ when 'FATAL' then :red
21
+ when 'WARN' then :yellow
22
+ when 'INFO' then :blue
23
+ when 'DEBUG' then '333333'
24
+ else :default
25
+ end
26
+ msg = msg.color(color)
27
+ end
28
+
29
+ old_formatter.call level, time, prog, msg
30
+ end
31
+ end
32
+
33
+ %w[ debug info warn fatal ].each do |level|
34
+ define_method level do |msg, opts = {}|
35
+ __getobj__.__send__ level, msg
36
+ end
37
+ end
38
+
39
+ def inspect object
40
+ info object.to_yaml[4..-1].strip.indent
41
+ end
42
+ end
43
+
44
+ def logger=(logger)
45
+ @logger = logger
46
+ end
47
+
48
+ def logger
49
+ @logger ||= begin
50
+ logger = Logger.new STDOUT
51
+ logger.level = Logger::DEBUG
52
+ logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
53
+ PrettyLogger.new logger
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,82 @@
1
+ require 'diffy'
2
+
3
+ module Cauchy
4
+ class Migration
5
+
6
+ CREATION_SETTINGS = %w[
7
+ codec
8
+ number_of_shards
9
+ ]
10
+
11
+ attr_accessor :schema, :new_schema
12
+
13
+ def initialize(schema, new_schema)
14
+ @schema = schema
15
+ @new_schema = new_schema
16
+ end
17
+
18
+ def changes_mappings?
19
+ (new_schema.types - schema.types).any? ||
20
+ (new_schema.types & schema.types).any? do |type|
21
+ mapping, new_mapping = schema.mapping_for(type), new_schema.mapping_for(type)
22
+ removed_fields = mapping.keys - new_mapping.keys
23
+ mapping.except(*removed_fields) != new_mapping
24
+ end
25
+ end
26
+
27
+ def changes_existing_mappings?
28
+ (new_schema.types & schema.types).any? do |type|
29
+ mapping, new_mapping = schema.mapping_for(type), new_schema.mapping_for(type)
30
+ common_fields = mapping.keys & new_mapping.keys
31
+ mapping.slice(*common_fields) != new_mapping.slice(*common_fields)
32
+ end
33
+ end
34
+
35
+ def changed_settings
36
+ new_schema.settings.select do |name, setting|
37
+ schema.settings[name] != setting
38
+ end.to_h
39
+ end
40
+
41
+ def changes_settings?
42
+ changed_settings.present?
43
+ end
44
+
45
+ def changes_creation_settings?
46
+ schema.settings.slice(*CREATION_SETTINGS).except(*removed_settings) !=
47
+ new_schema.settings.slice(*CREATION_SETTINGS)
48
+ end
49
+
50
+ def removed_settings
51
+ schema.settings.keys - new_schema.settings.keys
52
+ end
53
+
54
+ def requires_reindex?
55
+ changes_creation_settings? || changes_existing_mappings?
56
+ end
57
+
58
+ def up_to_date?
59
+ !(changes_settings? || changes_mappings?)
60
+ end
61
+
62
+ def mappings_diffs
63
+ (schema.mappings.keys | new_schema.mappings.keys).map do |type|
64
+ diff = Diffy::Diff.new(
65
+ JSON.pretty_generate(schema.mappings[type] || {}) + "\n",
66
+ JSON.pretty_generate(new_schema.mappings[type] || {}) + "\n",
67
+ context: 3
68
+ )
69
+ [type, diff]
70
+ end.to_h
71
+ end
72
+
73
+ def settings_diff
74
+ Diffy::Diff.new(
75
+ JSON.pretty_generate(schema.settings.except(*removed_settings)) + "\n",
76
+ JSON.pretty_generate(new_schema.settings.except(*removed_settings)) + "\n",
77
+ context: 3
78
+ )
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,249 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require 'active_support/core_ext/object/blank'
3
+ require 'ruby-progressbar'
4
+ require 'json'
5
+
6
+ require 'cauchy/index_schema'
7
+ require 'cauchy/migration'
8
+
9
+ module Cauchy
10
+
11
+ class MigrationError < StandardError
12
+ end
13
+
14
+ class UnknownIndexSchemaError < MigrationError
15
+ def initialize(name)
16
+ super("No index schema defined for \"#{name}\"")
17
+ end
18
+ end
19
+
20
+ class MultipleIndexAliasError < MigrationError
21
+ def initialize(name)
22
+ super("Multiple index aliases found for \"#{name}\"")
23
+ end
24
+ end
25
+
26
+ class NoIndexSchemasError < MigrationError
27
+ def initialize
28
+ super("No index schemas defined")
29
+ end
30
+ end
31
+
32
+ class Migrator
33
+
34
+ class << self
35
+
36
+ def migrate(client, index_paths = nil, target_index = nil, options = {})
37
+ with_schemas(index_paths, target_index) do |schema|
38
+ new(client, schema).migrate options
39
+ end
40
+ end
41
+
42
+ def status(client, index_paths = nil, target_index = nil)
43
+ with_schemas(index_paths, target_index) do |schema|
44
+ new(client, schema).status
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def with_schemas(index_paths, target_index = nil)
51
+ found_target = false
52
+ IndexSchema.load_schemas(index_paths)
53
+
54
+ raise NoIndexSchemasError unless IndexSchema.schemas.any?
55
+
56
+ IndexSchema.schemas.each do |index_alias, schema|
57
+ if target_index.nil? || target_index == index_alias
58
+ found_target = true
59
+ yield schema
60
+ end
61
+ end
62
+
63
+ raise UnknownIndexSchemaError, target_index unless found_target
64
+ end
65
+
66
+ end
67
+
68
+ attr_reader :client, :schema
69
+
70
+ def initialize(client, schema)
71
+ @client = client
72
+ @schema = schema
73
+ end
74
+
75
+ delegate :index_alias, :version, to: :schema
76
+
77
+ def new_index_name
78
+ "#{index_alias}_#{version}"
79
+ end
80
+
81
+ def new_index
82
+ @new_index ||= client.index(new_index_name)
83
+ end
84
+
85
+ def old_index
86
+ @old_index ||= client.index(resolve_index)
87
+ end
88
+
89
+ def migration
90
+ @migration ||= Migration.new(old_schema, schema)
91
+ end
92
+
93
+ def migrate(options = {})
94
+ raise MultipleIndexAliasError, index_alias if index_aliases.keys.length > 1
95
+
96
+ if options[:reindex] || !old_index.exists?
97
+ return reindex(options)
98
+ end
99
+
100
+ if migration.up_to_date?
101
+ log 'Index is up-to-date.'
102
+ return true
103
+ end
104
+
105
+ if migration.requires_reindex?
106
+ log migration_diff, :unknown
107
+ log 'Requires reindexing!', :warn
108
+ return false
109
+ end
110
+
111
+ if migration.changes_settings?
112
+ log 'Updating index settings... ' do
113
+ begin
114
+ old_index.close if options[:close_index]
115
+ old_index.settings = migration.changed_settings
116
+ rescue Elastic::CannotUpdateNonDynamicSettingsError
117
+ log migration_diff, :unknown
118
+ raise
119
+ ensure
120
+ old_index.open if options[:close_index]
121
+ end
122
+ 'done.'
123
+ end
124
+ end
125
+
126
+ if migration.changes_mappings?
127
+ log 'Updating index mappings... ' do
128
+ old_index.mappings = migration.new_schema.mappings
129
+ 'done.'
130
+ end
131
+ end
132
+ end
133
+
134
+ def reindex(options = {})
135
+ if old_index.exists?
136
+ if migration.up_to_date?
137
+ log 'Index is up-to-date.'
138
+ return true
139
+ elsif !migration.requires_reindex?
140
+ log 'Does not require reindexing... skipping.'
141
+ return false
142
+ end
143
+ end
144
+
145
+ begin
146
+ log "Creating new index #{new_index_name}"
147
+ new_index.create settings: schema.settings, mappings: schema.mappings
148
+ rescue Elastic::IndexAlreadyExistsError
149
+ log "Index #{new_index_name} already exists, continuing...", :warn
150
+ end
151
+
152
+ reindex_data if old_index.exists?
153
+
154
+ cleanup options
155
+
156
+ true
157
+ end
158
+
159
+ def reindex_data
160
+ log 'Reindexing...'
161
+ progress_bar = nil
162
+ old_index.scroll do |documents, total|
163
+ docs = documents.map do |h|
164
+ {
165
+ index: {
166
+ _index: new_index.name,
167
+ _type: h['_type'],
168
+ _id: h['_id'],
169
+ data: h['_source']
170
+ }
171
+ }
172
+ end
173
+
174
+ client.bulk docs
175
+
176
+ progress_bar ||= ProgressBar.create(total: total, throttle_rate: 0.1, format: '%a |%B| %e')
177
+ progress_bar.progress += docs.size
178
+ end
179
+ end
180
+
181
+ def status
182
+ if migration.up_to_date?
183
+ log 'Index is up-to-date.'
184
+ else
185
+ log migration_diff, :unknown
186
+ log 'Requires reindexing!', :warn if migration.requires_reindex?
187
+ end
188
+ end
189
+
190
+ def inspect
191
+ "#<#{self.class.name}"\
192
+ " index_alias=\"#{index_alias}\""\
193
+ " version=\"#{version[0..6]}\">"
194
+ end
195
+
196
+ def old_schema
197
+ @old_schema ||= begin
198
+ IndexSchema.new(index_alias).tap do |schema|
199
+ schema.mappings = old_index.mappings
200
+ schema.settings = old_index.settings
201
+ end
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ def cleanup(options)
208
+ log "Aliasing #{new_index.name} => #{index_alias}"
209
+ if old_index.name == index_alias
210
+ old_index.delete if old_index.exists?
211
+ new_index.alias = index_alias
212
+ else
213
+ client.update_aliases({
214
+ actions: [
215
+ { add: { alias: index_alias, index: new_index.name } },
216
+ { remove: { alias: index_alias, index: old_index.name } }
217
+ ]
218
+ })
219
+ old_index.delete if options.fetch :cleanup, true
220
+ end
221
+ end
222
+
223
+ def migration_diff
224
+ mappings_diff = migration.mappings_diffs.map do |type, diff|
225
+ diff = diff.to_s(:color).rstrip
226
+ "Mapping type=#{type}:\n" + diff if diff.present?
227
+ end.compact.join("\n")
228
+
229
+ settings_diff = migration.settings_diff.to_s(:color).rstrip
230
+ settings_diff = "Settings:\n" + settings_diff if settings_diff.present?
231
+
232
+ [mappings_diff, settings_diff].select(&:present?).join("\n\n")
233
+ end
234
+
235
+ def resolve_index
236
+ index_aliases.keys.first || index_alias
237
+ end
238
+
239
+ def index_aliases
240
+ client.index(index_alias).aliases
241
+ end
242
+
243
+ def log(msg, method = :info, &block)
244
+ Cauchy.logger.send(method, msg)
245
+ Cauchy.logger.send(method, block.call) if block_given?
246
+ end
247
+
248
+ end
249
+ end
@@ -0,0 +1,3 @@
1
+ module Cauchy
2
+ VERSION = '0.1.1'
3
+ end
data/lib/cauchy.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'cauchy/elastic/client'
2
+ require 'cauchy/migrator'
3
+ require 'cauchy/logging'
4
+ require 'cauchy/version'
metadata ADDED
@@ -0,0 +1,248 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cauchy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Paul Hamera
8
+ - Evan Owen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2016-12-29 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '4.2'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '4.2'
28
+ - !ruby/object:Gem::Dependency
29
+ name: diffy
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '3.1'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.1'
42
+ - !ruby/object:Gem::Dependency
43
+ name: elasticsearch
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '1.0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: indentation
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '0.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '0.0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: json
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '1.8'
77
+ type: :runtime
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '1.8'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rainbow
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '2.1'
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '2.1'
98
+ - !ruby/object:Gem::Dependency
99
+ name: ruby-progressbar
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '1.8'
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '1.8'
112
+ - !ruby/object:Gem::Dependency
113
+ name: thor
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '0.18'
119
+ type: :runtime
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '0.18'
126
+ - !ruby/object:Gem::Dependency
127
+ name: bundler
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: '1.11'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - "~>"
138
+ - !ruby/object:Gem::Version
139
+ version: '1.11'
140
+ - !ruby/object:Gem::Dependency
141
+ name: rake
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - "~>"
145
+ - !ruby/object:Gem::Version
146
+ version: '10.0'
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - "~>"
152
+ - !ruby/object:Gem::Version
153
+ version: '10.0'
154
+ - !ruby/object:Gem::Dependency
155
+ name: rspec
156
+ requirement: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - "~>"
159
+ - !ruby/object:Gem::Version
160
+ version: '3.0'
161
+ type: :development
162
+ prerelease: false
163
+ version_requirements: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - "~>"
166
+ - !ruby/object:Gem::Version
167
+ version: '3.0'
168
+ - !ruby/object:Gem::Dependency
169
+ name: rspec-its
170
+ requirement: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - "~>"
173
+ - !ruby/object:Gem::Version
174
+ version: '1.2'
175
+ type: :development
176
+ prerelease: false
177
+ version_requirements: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - "~>"
180
+ - !ruby/object:Gem::Version
181
+ version: '1.2'
182
+ - !ruby/object:Gem::Dependency
183
+ name: pry
184
+ requirement: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '0'
189
+ type: :development
190
+ prerelease: false
191
+ version_requirements: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ description: An elasticsearch schema management tool
197
+ email:
198
+ - paul@zinc.it
199
+ - evan@zinc.it
200
+ executables:
201
+ - cauchy
202
+ extensions: []
203
+ extra_rdoc_files: []
204
+ files:
205
+ - ".gitignore"
206
+ - ".rspec"
207
+ - ".travis.yml"
208
+ - Gemfile
209
+ - LICENSE.txt
210
+ - README.md
211
+ - Rakefile
212
+ - bin/cauchy
213
+ - cauchy.gemspec
214
+ - lib/cauchy.rb
215
+ - lib/cauchy/cli.rb
216
+ - lib/cauchy/elastic/client.rb
217
+ - lib/cauchy/elastic/index.rb
218
+ - lib/cauchy/index_schema.rb
219
+ - lib/cauchy/index_schema/normalization.rb
220
+ - lib/cauchy/logging.rb
221
+ - lib/cauchy/migration.rb
222
+ - lib/cauchy/migrator.rb
223
+ - lib/cauchy/version.rb
224
+ homepage: https://github.com/cotap
225
+ licenses:
226
+ - MIT
227
+ metadata: {}
228
+ post_install_message:
229
+ rdoc_options: []
230
+ require_paths:
231
+ - lib
232
+ required_ruby_version: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: 2.0.0
237
+ required_rubygems_version: !ruby/object:Gem::Requirement
238
+ requirements:
239
+ - - ">="
240
+ - !ruby/object:Gem::Version
241
+ version: '0'
242
+ requirements: []
243
+ rubyforge_project:
244
+ rubygems_version: 2.4.5.1
245
+ signing_key:
246
+ specification_version: 4
247
+ summary: An elasticsearch schema management tool
248
+ test_files: []