elasticsnap 0.1.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,72 @@
1
+ module Elasticsnap
2
+ class FreezeElasticsearch
3
+ class Error < StandardError; end
4
+ class FlushFailed < Error; end
5
+ class DisableFlushFailed < Error; end
6
+ class EnableFlushFailed < Error; end
7
+
8
+ attr_accessor :url
9
+
10
+ def initialize(url: nil)
11
+ raise ArgumentError, 'url required' if url.nil?
12
+
13
+ @url = url
14
+ end
15
+
16
+ def freeze(&block)
17
+ begin
18
+ flush
19
+ disable_flush
20
+
21
+ block.call unless block.nil?
22
+ ensure
23
+ enable_flush
24
+ end
25
+ end
26
+
27
+ def flush
28
+ Flex::Configuration.http_client.base_uri = url
29
+ result = Flex.flush_index(index: '_all')
30
+
31
+ raise FlushFailed unless result.fetch('ok', false) == true
32
+
33
+ result
34
+ end
35
+
36
+ def disable_flush
37
+ Flex::Configuration.http_client.base_uri = url
38
+ result = Flex.update_index_settings(
39
+ index: '_all',
40
+ data: {
41
+ index: {
42
+ translog: {
43
+ disable_flush: true
44
+ }
45
+ }
46
+ }
47
+ )
48
+
49
+ raise DisableFlushFailed unless result.fetch('ok', false) == true
50
+
51
+ result
52
+ end
53
+
54
+ def enable_flush
55
+ Flex::Configuration.http_client.base_uri = url
56
+ result = Flex.update_index_settings(
57
+ index: '_all',
58
+ data: {
59
+ index: {
60
+ translog: {
61
+ disable_flush: false
62
+ }
63
+ }
64
+ }
65
+ )
66
+
67
+ raise EnableFlushFailed unless result.fetch('ok', false) == true
68
+
69
+ result
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,55 @@
1
+ require 'capistrano/cli'
2
+
3
+ module Elasticsnap
4
+ class FreezeFs
5
+ attr_accessor :mount
6
+ attr_accessor :security_group
7
+ attr_accessor :ssh_user
8
+
9
+ def initialize(mount: nil, security_group: nil, ssh_user: nil)
10
+ raise ArgumentError, 'mount required' if mount.nil?
11
+ raise ArgumentError, 'security_group required' if security_group.nil?
12
+
13
+ @mount = mount
14
+ @security_group = security_group
15
+ @ssh_user = ssh_user
16
+ end
17
+
18
+ def freeze(&block)
19
+ begin
20
+ sync
21
+ freeze_fs
22
+
23
+ block.call unless block.nil?
24
+ ensure
25
+ unfreeze_fs
26
+ end
27
+ end
28
+
29
+ def sync
30
+ run_command('sudo sync')
31
+ end
32
+
33
+ def freeze_fs
34
+ run_command 'sudo fsfreeze', '-f', mount
35
+ end
36
+
37
+ def unfreeze_fs
38
+ run_command 'sudo fsfreeze', '-u', mount
39
+ end
40
+
41
+ private
42
+ def run_command(*command)
43
+ stream(*command)
44
+ end
45
+
46
+ def stream(*command)
47
+ command = [command].flatten.join(' ')
48
+ capistrano_config.stream(command, hosts: SecurityGroup.new(name: security_group).ssh_hosts(ssh_user: ssh_user))
49
+ end
50
+
51
+ def capistrano_config
52
+ @cap_config ||= Capistrano::Configuration.new
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ require 'elasticsnap/config'
2
+
3
+ module Elasticsnap
4
+ class SecurityGroup
5
+ attr_accessor :name
6
+
7
+ def initialize(name: nil)
8
+ raise ArgumentError, 'name required' if name.nil?
9
+
10
+ @name = name
11
+ end
12
+
13
+ def hosts
14
+ @hosts ||= connection.servers.all('group-id' => id)
15
+ end
16
+
17
+ def ssh_hosts(ssh_user: nil)
18
+ hosts.map do |host|
19
+ if ssh_user
20
+ "#{ssh_user}@#{host.dns_name}"
21
+ else
22
+ host.dns_name
23
+ end
24
+ end
25
+ end
26
+
27
+ def volumes(cluster_name: nil)
28
+ filters = { 'attachment.instance-id' => hosts.map(&:id) }
29
+ filters.merge!('tag:ClusterName' => cluster_name) if cluster_name
30
+ connection.volumes.all(filters)
31
+ end
32
+
33
+ def fog_group
34
+ @fog_group ||= connection.security_groups.all('group-name' => name).first
35
+ end
36
+
37
+ def id
38
+ @id ||= fog_group.group_id
39
+ end
40
+
41
+ def reload
42
+ @hosts = @id = nil
43
+ end
44
+
45
+ private
46
+ def connection
47
+ Config.fog_connection
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,56 @@
1
+ require 'elasticsnap/verify_es_cluster_status'
2
+ require 'elasticsnap/freeze_elasticsearch'
3
+ require 'elasticsnap/freeze_fs'
4
+ require 'elasticsnap/ebs_snapshot'
5
+
6
+ module Elasticsnap
7
+ class Snapshot
8
+ attr_accessor :security_group
9
+ attr_accessor :url
10
+ attr_accessor :mount
11
+ attr_accessor :quorum_nodes
12
+ attr_accessor :wait_timeout
13
+ attr_accessor :cluster_name
14
+ attr_accessor :ssh_user
15
+
16
+ def initialize(security_group: nil, url: nil, mount: nil, quorum_nodes: nil, wait_timeout: 30, cluster_name: nil, ssh_user: nil)
17
+ raise ArgumentError, "security_group required" if security_group.nil?
18
+ raise ArgumentError, "url required" if url.nil?
19
+ raise ArgumentError, "mount required" if mount.nil?
20
+ raise ArgumentError, "quorum_nodes required" if quorum_nodes.nil?
21
+
22
+ @security_group = security_group
23
+ @url = url
24
+ @mount = mount
25
+ @quorum_nodes = quorum_nodes
26
+ @wait_timeout = wait_timeout
27
+ @cluster_name = cluster_name
28
+ @ssh_user = ssh_user
29
+ end
30
+
31
+ def call
32
+ verify_es_cluster_status!
33
+ freeze_es do
34
+ freeze_fs do
35
+ ebs_snapshot!
36
+ end
37
+ end
38
+ end
39
+
40
+ def verify_es_cluster_status!
41
+ VerifyEsClusterStatus.new(url: url, quorum_nodes: quorum_nodes, wait_timeout: wait_timeout).verify!
42
+ end
43
+
44
+ def freeze_es(&block)
45
+ FreezeElasticsearch.new(url: url).freeze(&block)
46
+ end
47
+
48
+ def freeze_fs(&block)
49
+ FreezeFs.new(mount: mount, security_group: security_group, ssh_user: ssh_user).freeze(&block)
50
+ end
51
+
52
+ def ebs_snapshot!
53
+ EbsSnapshot.new(security_group: security_group, cluster_name: cluster_name).snapshot
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,13 @@
1
+ require 'elasticsnap/config'
2
+
3
+ module Elasticsnap
4
+ module SnapshotVolumeTags
5
+ def add_volume_tags(volume)
6
+ connection.create_tags(self.id, volume.tags)
7
+ end
8
+
9
+ def connection
10
+ Config.fog_connection
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,42 @@
1
+ require 'flex'
2
+
3
+ module Elasticsnap
4
+ class VerifyEsClusterStatus
5
+ class Error < StandardError; end
6
+ class StatusRed < Error; end
7
+ class NoQuorum < Error; end
8
+
9
+ attr_accessor :url
10
+ attr_accessor :quorum_nodes
11
+ attr_accessor :wait_timeout
12
+
13
+ def initialize(url: nil, quorum_nodes: nil, wait_timeout: 30)
14
+ raise ArgumentError, 'url required' if url.nil?
15
+ raise ArgumentError, 'quorum_nodes required' if quorum_nodes.nil?
16
+
17
+ @url = url
18
+ @quorum_nodes = quorum_nodes
19
+ @wait_timeout = wait_timeout
20
+ end
21
+
22
+ def verify!
23
+ wait_for_green_quorum!
24
+ end
25
+
26
+ def wait_for_green_quorum!
27
+ Flex::Configuration.http_client.base_uri = url
28
+ health = Flex.cluster_health(
29
+ params: {
30
+ wait_for_status: 'yellow',
31
+ wait_for_nodes: "gt(#{quorum_nodes})",
32
+ timeout: "#{wait_timeout}s"
33
+ }
34
+ )
35
+
36
+ raise StatusRed if health['status'] == 'red'
37
+ raise NoQuorum if health['number_of_nodes'] < quorum_nodes
38
+
39
+ health
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ module Elasticsnap
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Snapshots' do
4
+ before do
5
+ WebMock.disable_net_connect!
6
+ #WebMock.allow_net_connect!
7
+ end
8
+
9
+ let(:timeout) { 2 }
10
+ let(:quorum) { 2 }
11
+ let(:args) { "-u localhost:9200 -v /dev/sda -q #{quorum} -t #{timeout}" }
12
+
13
+ it 'User creates snapshot' do
14
+ pending 'Add stubs for ssh connections and fog'
15
+ health_check = stub_request(:get, 'localhost:9200/_cluster/health/').with(
16
+ query: hash_including({
17
+ wait_for_nodes: "gt(#{quorum})",
18
+ wait_for_status: 'yellow'
19
+ })
20
+ ).to_return(
21
+ body: MultiJson.dump({
22
+ 'cluster_name' => 'foobar',
23
+ 'status' => 'green',
24
+ 'timed_out' => false,
25
+ 'number_of_nodes' => 2,
26
+ 'number_of_data_nodes' => 2,
27
+ 'active_primary_shards' => 5,
28
+ 'active_shards' => 10,
29
+ 'relocating_shards' => 0,
30
+ 'initializing_shards' => 0,
31
+ 'unassigned_shards' => 0
32
+ })
33
+ )
34
+
35
+ flush = stub_request(:post, 'localhost:9200/_all/_flush').to_return(
36
+ body: MultiJson.dump({
37
+ 'ok' => true
38
+ })
39
+ )
40
+
41
+ disable_flush = stub_request(:put, 'localhost:9200/_all/_settings').with(
42
+ body: MultiJson.dump({
43
+ index: {
44
+ translog: {
45
+ disable_flush: true
46
+ }
47
+ }
48
+ })
49
+ ).to_return(
50
+ body: MultiJson.dump({
51
+ 'ok' => true
52
+ })
53
+ )
54
+
55
+ enable_flush = stub_request(:put, 'localhost:9200/_all/_settings').with(
56
+ body: MultiJson.dump({
57
+ index: {
58
+ translog: {
59
+ disable_flush: false
60
+ }
61
+ }
62
+ })
63
+ ).to_return(
64
+ body: MultiJson.dump({
65
+ 'ok' => true
66
+ })
67
+ )
68
+
69
+ start(:snapshot, args)
70
+ expect(health_check).to have_been_requested
71
+ expect(flush).to have_been_requested
72
+ expect(disable_flush).to have_been_requested
73
+ expect(enable_flush).to have_been_requested
74
+ end
75
+ end
@@ -0,0 +1,63 @@
1
+ require 'spec_helper'
2
+
3
+ describe Elasticsnap::EbsSnapshot do
4
+ it 'requires security group' do
5
+ expect { described_class.new }.to raise_error ArgumentError
6
+ end
7
+
8
+ let(:security_group) { double(:security_group, name: 'elasticsearch') }
9
+ let(:snapshotter) { described_class.new(security_group: security_group.name) }
10
+ let(:snapshots) { [double(:snapshot1, add_volume_tags: true), double(:snapshot2, add_volume_tags: true)] }
11
+ let(:volumes) { [double(:volume1, id: 'vol-123', snapshot: snapshots[0]), double(:volume2, id: 'vol-234', snapshot: snapshots[1])] }
12
+
13
+ describe '#snapshot' do
14
+ before do
15
+ allow(snapshotter).to receive(:wrap_snapshot) do |snapshot|
16
+ snapshot
17
+ end
18
+
19
+ allow(Elasticsnap::SecurityGroup).to receive(:new).with(name: security_group.name).and_return(security_group)
20
+ allow(security_group).to receive(:volumes).and_return(volumes)
21
+ end
22
+
23
+ it 'gets a list of volumes from the security group' do
24
+ snapshotter.snapshot
25
+ expect(Elasticsnap::SecurityGroup).to have_received(:new).with(name: security_group.name)
26
+ expect(security_group).to have_received(:volumes)
27
+ end
28
+
29
+ it 'snapshots each volume' do
30
+ snapshotter.snapshot
31
+ volumes.each do |volume|
32
+ expect(volume).to have_received(:snapshot)
33
+ end
34
+ end
35
+
36
+ it 'adds volume tags to each snapshot' do
37
+ snapshotter.snapshot
38
+ snapshots.each do |snapshot|
39
+ expect(snapshot).to have_received(:add_volume_tags)
40
+ end
41
+ end
42
+ end
43
+
44
+ describe '#wrap_snapshot' do
45
+ let(:snapshot_body) { double(:snapshot_body) }
46
+ let(:snapshot_response) { double(:snapshot_response, body: snapshot_body) }
47
+ let(:snapshot) { double(:snapshot) }
48
+
49
+ before do
50
+ allow(Fog::Compute::AWS::Snapshot).to receive(:new).with(snapshot_body).and_return(snapshot)
51
+ end
52
+
53
+ it 'bundles the response in a snapshot model' do
54
+ snapshotter.wrap_snapshot(snapshot_response)
55
+ expect(Fog::Compute::AWS::Snapshot).to have_received(:new)
56
+ end
57
+
58
+ it 'extends the snapshot instance with SnapshotVolumeTags' do
59
+ expect(snapshot).to receive(:extend).with(Elasticsnap::SnapshotVolumeTags)
60
+ snapshotter.wrap_snapshot(snapshot_response)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,102 @@
1
+ require 'spec_helper'
2
+
3
+ describe Elasticsnap::FreezeElasticsearch do
4
+ it 'requires url' do
5
+ expect { described_class.new }.to raise_error ArgumentError
6
+ end
7
+
8
+ let(:freeze) { described_class.new(url: 'localhost:9200')}
9
+ let(:disable_flush_hash) do
10
+ {
11
+ index: '_all',
12
+ data: {
13
+ index: {
14
+ translog: {
15
+ disable_flush: true
16
+ }
17
+ }
18
+ }
19
+ }
20
+ end
21
+ let(:enable_flush_hash) do
22
+ {
23
+ index: '_all',
24
+ data: {
25
+ index: {
26
+ translog: {
27
+ disable_flush: false
28
+ }
29
+ }
30
+ }
31
+ }
32
+ end
33
+
34
+ before do
35
+ allow(Flex).to receive(:flush_index).and_return({'ok' => true})
36
+ allow(Flex).to receive(:update_index_settings).with(disable_flush_hash).and_return({'ok' => true})
37
+ allow(Flex).to receive(:update_index_settings).with(enable_flush_hash).and_return({'ok' => true})
38
+ end
39
+
40
+ it 'flushes elasticsearch' do
41
+ freeze.freeze
42
+ expect(Flex).to have_received(:flush_index).with(index: '_all')
43
+ end
44
+
45
+ context 'when flushing fails' do
46
+ before do
47
+ allow(Flex).to receive(:flush_index).and_return({'ok' => false})
48
+ end
49
+
50
+ it 'raises FlushFailed' do
51
+ expect { freeze.freeze }.to raise_error Elasticsnap::FreezeElasticsearch::FlushFailed
52
+ end
53
+ end
54
+
55
+ it 'disables flushing' do
56
+ freeze.freeze
57
+ expect(Flex).to have_received(:update_index_settings).with(disable_flush_hash)
58
+ end
59
+
60
+ context 'when disable flushing fails' do
61
+ before do
62
+ allow(Flex).to receive(:update_index_settings).with(disable_flush_hash).and_return({'ok' => false})
63
+ end
64
+
65
+ it 'raises DisableFlushFailed' do
66
+ expect { freeze.freeze }.to raise_error Elasticsnap::FreezeElasticsearch::DisableFlushFailed
67
+ end
68
+ end
69
+
70
+ it 'calls the passed block' do
71
+ block_called = false
72
+ freeze.freeze do
73
+ block_called = true
74
+ end
75
+
76
+ expect(block_called).to be_true
77
+ end
78
+
79
+ it 'enables flushing' do
80
+ freeze.freeze
81
+ expect(Flex).to have_received(:update_index_settings).with(enable_flush_hash)
82
+ end
83
+
84
+ context 'when enable flushing fails' do
85
+ before do
86
+ allow(Flex).to receive(:update_index_settings).with(enable_flush_hash).and_return({'ok' => false})
87
+ end
88
+
89
+ it 'raises EnableFlushFailed' do
90
+ expect { freeze.freeze }.to raise_error Elasticsnap::FreezeElasticsearch::EnableFlushFailed
91
+ end
92
+ end
93
+
94
+ context 'when an exception is raised from the block' do
95
+ it 'enables flushing' do
96
+ expect { freeze.freeze do
97
+ raise 'BOOM'
98
+ end }.to raise_error 'BOOM'
99
+ expect(Flex).to have_received(:update_index_settings).with(enable_flush_hash)
100
+ end
101
+ end
102
+ end