cloudfinder-ec2 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,265 @@
1
+ describe Cloudfinder::EC2::Clusterfinder do
2
+
3
+ let (:client) { Aws::EC2::Client.new(stub_responses: true) }
4
+ let (:reservations) { [] }
5
+ let (:cluster_name) { 'production' }
6
+ let (:region) { 'some-region' }
7
+
8
+ before (:each) do
9
+ allow(Aws::EC2::Client).to receive(:new).and_return(client)
10
+ end
11
+
12
+ shared_examples_for 'cluster object response' do
13
+ it 'provides cluster name in returned cluster' do
14
+ expect(cluster.cluster_name).to eq cluster_name
15
+ end
16
+ end
17
+
18
+ shared_examples_for 'empty cluster' do
19
+ include_examples 'cluster object response'
20
+
21
+ it 'returns empty cluster' do
22
+ expect(cluster).to be_empty
23
+ end
24
+ end
25
+
26
+ shared_examples_for 'running cluster with roles' do |roles|
27
+ include_examples 'cluster object response'
28
+
29
+ it 'returns running cluster' do
30
+ expect(cluster).to be_running
31
+ end
32
+
33
+ it 'has only the expected cluster roles' do
34
+ expect(cluster.list_roles).to eq roles
35
+ end
36
+ end
37
+
38
+ shared_examples_for 'cluster with instance in role' do |role, instance_index, instance_id|
39
+ it "has #{role} role in cluster" do
40
+ expect(cluster).to have_role(role)
41
+ end
42
+
43
+ it "has instance #{instance_id} as #{role}:##{instance_index}" do
44
+ expect(cluster.list_role_instances(role)[instance_index].instance_id).to eq instance_id
45
+ end
46
+
47
+ it "populates #{instance_id} public IP" do
48
+ expect(cluster.list_role_instances(role)[instance_index].public_ip).to eq public_ip(instance_id)
49
+ end
50
+
51
+ it "populates #{instance_id} private IP" do
52
+ expect(cluster.list_role_instances(role)[instance_index].private_ip).to eq private_ip(instance_id)
53
+ end
54
+
55
+ it "populates #{instance_id} public DNS" do
56
+ expect(cluster.list_role_instances(role)[instance_index].public_dns).to eq public_dns(instance_id)
57
+ end
58
+
59
+ it "populates #{instance_id} private DNS" do
60
+ expect(cluster.list_role_instances(role)[instance_index].private_dns).to eq private_dns(instance_id)
61
+ end
62
+ end
63
+
64
+ describe '#find' do
65
+ let (:cluster) { subject.find(region: region, cluster_name: cluster_name) }
66
+
67
+ context 'with invalid arguments' do
68
+ it 'throws without region' do
69
+ expect { subject.find(cluster_name: cluster_name) }.to raise_error(ArgumentError)
70
+ end
71
+
72
+ it 'throws without cluster name' do
73
+ expect { subject.find(region: region) }.to raise_error(ArgumentError)
74
+ end
75
+ end
76
+
77
+ context 'when searching for EC2 instances' do
78
+ let (:client) { spy(Aws::EC2::Client) }
79
+
80
+ it 'creates AWS API client for the specified region' do
81
+ expect(Aws::EC2::Client).to receive(:new).with(region: 'any-region').once.and_return(client)
82
+ subject.find(region: 'any-region', cluster_name: cluster_name)
83
+ end
84
+
85
+ it 'requests details of running EC2 instances' do
86
+ subject.find(region: 'us-east-1', cluster_name: 'i12345678')
87
+ expect(client).to have_received(:describe_instances).once.with(filters: [{ name: 'instance-state-name', values: ['running'] }])
88
+ end
89
+ end
90
+
91
+ context 'when AWS API throws exceptions' do
92
+ it 'bubbles exceptions to caller' do
93
+ client.stub_responses(:describe_instances, Aws::Errors::MissingCredentialsError)
94
+ expect { subject.find(region: 'us-east-1', cluster_name: 'any') }.to raise_error(Aws::Errors::MissingCredentialsError)
95
+ end
96
+ end
97
+
98
+ context 'when no instances are running' do
99
+ before (:each) do
100
+ client.stub_responses(:describe_instances, reservations: [])
101
+ end
102
+
103
+ include_examples 'empty cluster'
104
+ end
105
+
106
+ context 'when one instance is running' do
107
+ before (:each) do
108
+ stub_describe_instances(stub_reservation(running_instance))
109
+ end
110
+
111
+ context 'with no tags' do
112
+ let(:running_instance) { stub_instance }
113
+ include_examples 'empty cluster'
114
+ end
115
+
116
+ context 'with irrelevant tags' do
117
+ let(:running_instance) { stub_instance(tags: { 'name' => 'qa-server' }) }
118
+ include_examples 'empty cluster'
119
+ end
120
+
121
+ context 'with cloudfinder-cluster tag for another cluster' do
122
+ let(:running_instance) { stub_instance(cluster_tag: 'another-cluster', role_tag: 'db') }
123
+ include_examples 'empty cluster'
124
+ end
125
+
126
+ context 'tagged for correct cloudfinder-cluster without cloudfinder-role' do
127
+ let(:running_instance) { stub_instance(cluster_tag: cluster_name, role_tag: nil) }
128
+ include_examples 'empty cluster'
129
+ end
130
+
131
+ context 'tagged for correct cloudfinder-cluster with cloudfinder-role=db' do
132
+ let(:running_instance) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
133
+
134
+ include_examples('running cluster with roles', [:db])
135
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
136
+ end
137
+ end
138
+
139
+ context 'when two instances in single reservation' do
140
+ before (:each) do
141
+ stub_describe_instances(stub_reservation(instance_1, instance_2))
142
+ end
143
+
144
+ context 'with only one in this cluster' do
145
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
146
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: 'another-cluster', role_tag: 'app') }
147
+
148
+ include_examples('running cluster with roles', [:db])
149
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
150
+ end
151
+
152
+ context 'both in this cluster with same role' do
153
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
154
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: cluster_name, role_tag: 'db') }
155
+
156
+ include_examples('running cluster with roles', [:db])
157
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
158
+ include_examples('cluster with instance in role', :db, 1, 'i-00000002')
159
+ end
160
+
161
+ context 'both in this cluster with different roles' do
162
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
163
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: cluster_name, role_tag: 'app') }
164
+
165
+ include_examples('running cluster with roles', [:db, :app])
166
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
167
+ include_examples('cluster with instance in role', :app, 0, 'i-00000002')
168
+ end
169
+ end
170
+
171
+ context 'when three instances in two reservations' do
172
+ before (:each) do
173
+ stub_describe_instances(
174
+ stub_reservation(instance_1, instance_2),
175
+ stub_reservation(instance_3)
176
+ )
177
+ end
178
+
179
+ context 'with only two in this cluster' do
180
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: 'another-cluster', role_tag: 'db') }
181
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: 'another-cluster', role_tag: 'app') }
182
+ let (:instance_3) { stub_instance(id: 'i-00000003', cluster_tag: cluster_name, role_tag: 'app') }
183
+
184
+ include_examples('running cluster with roles', [:app])
185
+ include_examples('cluster with instance in role', :app, 0, 'i-00000003')
186
+ end
187
+
188
+ context 'all in this cluster with same role' do
189
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
190
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: cluster_name, role_tag: 'db') }
191
+ let (:instance_3) { stub_instance(id: 'i-00000003', cluster_tag: cluster_name, role_tag: 'db') }
192
+
193
+ include_examples('running cluster with roles', [:db])
194
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
195
+ include_examples('cluster with instance in role', :db, 1, 'i-00000002')
196
+ include_examples('cluster with instance in role', :db, 2, 'i-00000003')
197
+ end
198
+
199
+ context 'all in this cluster with different roles' do
200
+ let (:instance_1) { stub_instance(id: 'i-00000001', cluster_tag: cluster_name, role_tag: 'db') }
201
+ let (:instance_2) { stub_instance(id: 'i-00000002', cluster_tag: cluster_name, role_tag: 'app') }
202
+ let (:instance_3) { stub_instance(id: 'i-00000003', cluster_tag: cluster_name, role_tag: 'cache') }
203
+
204
+ include_examples('running cluster with roles', [:db, :app, :cache])
205
+ include_examples('cluster with instance in role', :db, 0, 'i-00000001')
206
+ include_examples('cluster with instance in role', :app, 0, 'i-00000002')
207
+ include_examples('cluster with instance in role', :cache, 0, 'i-00000003')
208
+ end
209
+ end
210
+ end
211
+
212
+ def stub_describe_instances(*reservations)
213
+ client.stub_responses(:describe_instances, reservations: reservations)
214
+ end
215
+
216
+ def stub_reservation(*instances)
217
+ { instances: instances }
218
+ end
219
+
220
+ def stub_instance(args = {})
221
+ id = args[:id] || 'i-00000001'
222
+ instance = {
223
+ instance_id: id,
224
+ public_ip_address: args[:public_ip] || public_ip(id), #46.137.0.1',
225
+ private_ip_address: args[:private_ip] || private_ip(id), #'10.248.0.1',
226
+ public_dns_name: args[:public_dns] || public_dns(id), #'ec2-46-137-0-1.eu-west-1.compute.amazonaws.com',
227
+ private_dns_name: args[:public_dns] || private_dns(id), #'ip-10-248-0-1.eu-west-1.compute.internal',
228
+ tags: []
229
+ }
230
+ (args[:tags] || {}).each do |key, value|
231
+ instance[:tags] << { key: key, value: value }
232
+ end
233
+
234
+ if args[:cluster_tag]
235
+ instance[:tags] << { key: Cloudfinder::EC2::CLUSTER_TAG_NAME, value: args[:cluster_tag] }
236
+ end
237
+
238
+ if args[:role_tag]
239
+ instance[:tags] << { key: Cloudfinder::EC2::ROLE_TAG_NAME, value: args[:role_tag] }
240
+ end
241
+
242
+ instance
243
+ end
244
+
245
+ def numeric_instance_id(instance_id)
246
+ instance_id[-2, 2].to_i
247
+ end
248
+
249
+ def private_ip(instance_id)
250
+ "10.248.0.#{numeric_instance_id(instance_id)}"
251
+ end
252
+
253
+ def public_ip(instance_id)
254
+ "46.137.0.#{numeric_instance_id(instance_id)}"
255
+ end
256
+
257
+ def private_dns(instance_id)
258
+ "ec2-46-137-0-#{numeric_instance_id(instance_id)}.eu-west-1.compute.amazonaws.com"
259
+ end
260
+
261
+ def public_dns(instance_id)
262
+ "ip-10-248-0-#{numeric_instance_id(instance_id)}.eu-west-1.compute.internal"
263
+ end
264
+
265
+ end
@@ -0,0 +1,131 @@
1
+ describe Cloudfinder::EC2::Command::List, focus: true do
2
+ let (:detector) { spy(Cloudfinder::EC2::Detector) }
3
+ let (:detector_result) { { cluster_name: detected_cluster, cluster_role: 'db', region: detected_region } }
4
+ let (:cluster_finder) { spy(Cloudfinder::EC2::Clusterfinder) }
5
+ let (:found_cluster) { double(Cloudfinder::EC2::Cluster) }
6
+ let (:cluster_hash) { { cluster_name: detected_cluster, roles: {} } }
7
+ let (:standard_out) { StringIO.new }
8
+ let (:error_out) { StringIO.new }
9
+ let (:detected_cluster) { 'qa' }
10
+ let (:detected_region) { 'eu-west-1' }
11
+
12
+ subject do
13
+ Cloudfinder::EC2::Command::List.new(
14
+ cluster_finder,
15
+ detector,
16
+ standard_out,
17
+ error_out
18
+ )
19
+ end
20
+
21
+ before :each do
22
+ allow(cluster_finder).to receive(:find).and_return found_cluster
23
+ allow(found_cluster).to receive(:to_hash).and_return cluster_hash
24
+ end
25
+
26
+ shared_examples_for 'render cluster as JSON' do
27
+ it 'prints cluster hash to standard out as JSON' do
28
+ expect(JSON.parse(standard_out.string)).to eq stringify_keys(cluster_hash)
29
+ end
30
+
31
+ it 'prints nothing to standard error' do
32
+ expect(error_out.string).to eq ''
33
+ end
34
+ end
35
+
36
+ shared_examples_for 'find cluster by specified name' do |name|
37
+ it 'finds cluster with specified cluster name' do
38
+ expect(cluster_finder).to have_received(:find).with(hash_including(cluster_name: name))
39
+ end
40
+ end
41
+
42
+ shared_examples_for 'find cluster by detected name' do
43
+ it 'finds cluster with detected cluster name' do
44
+ expect(cluster_finder).to have_received(:find).with(hash_including(cluster_name: detected_cluster))
45
+ end
46
+ end
47
+
48
+ shared_examples_for 'find cluster in specified region' do |region|
49
+ it 'finds cluster in specified region' do
50
+ expect(cluster_finder).to have_received(:find).with(hash_including(region: region))
51
+ end
52
+ end
53
+
54
+ shared_examples_for 'find cluster in detected region' do
55
+ it 'finds cluster in detected region' do
56
+ expect(cluster_finder).to have_received(:find).with(hash_including(region: detected_region))
57
+ end
58
+ end
59
+
60
+
61
+ describe '#execute' do
62
+ context 'when cluster name and region are specified' do
63
+ before :each do
64
+ subject.execute(cluster_name: 'other-cluster', region: 'custom-region')
65
+ end
66
+
67
+ it 'does not attempt automatic cluster detection' do
68
+ expect(detector).not_to have_received(:detect_cluster)
69
+ end
70
+
71
+ include_examples('find cluster by specified name', 'other-cluster')
72
+ include_examples('find cluster in specified region', 'custom-region')
73
+ include_examples 'render cluster as JSON'
74
+ end
75
+
76
+ context 'when only cluster name is specified' do
77
+ before :each do
78
+ expect(detector).to receive(:detect_cluster).and_return detector_result
79
+ subject.execute(cluster_name: 'other-cluster')
80
+ end
81
+
82
+ include_examples('find cluster by specified name', 'other-cluster')
83
+ include_examples('find cluster in detected region')
84
+ include_examples 'render cluster as JSON'
85
+ end
86
+
87
+ context 'when region is specified and cluster name can be detected' do
88
+ before :each do
89
+ expect(detector).to receive(:detect_cluster).and_return detector_result
90
+ subject.execute(region: 'custom-region')
91
+ end
92
+
93
+ include_examples('find cluster by detected name')
94
+ include_examples('find cluster in specified region', 'custom-region')
95
+ include_examples 'render cluster as JSON'
96
+ end
97
+
98
+ context 'when cluster detector throws exception' do
99
+ before :each do
100
+ expect(detector).to receive(:detect_cluster).and_raise Errno::ENETUNREACH
101
+ end
102
+
103
+ it 'rethrows the exception' do
104
+ expect { subject.execute }.to raise_error Errno::ENETUNREACH
105
+ end
106
+
107
+ it 'prints nothing to standard out' do
108
+ begin
109
+ subject.execute rescue StandardError
110
+ end
111
+ expect(standard_out.string).to eq ''
112
+ end
113
+
114
+ it 'prints exception header to stderr' do
115
+ begin
116
+ subject.execute rescue StandardError
117
+ end
118
+ expect(error_out.string).to match /This instance may not be running on EC2/
119
+ end
120
+ end
121
+ end
122
+
123
+ def stringify_keys(hash)
124
+ string_hash = {}
125
+ hash.each do |key, value|
126
+ string_hash[key.to_s] = value
127
+ end
128
+ string_hash
129
+ end
130
+
131
+ end
@@ -0,0 +1,152 @@
1
+ describe Cloudfinder::EC2::Detector do
2
+ describe '#detect' do
3
+ let (:ec2_client) { Aws::EC2::Client.new(stub_responses: true) }
4
+ let (:tags) { [] }
5
+
6
+ before (:each) do
7
+ allow(Aws::EC2::Client).to receive(:new).and_return(ec2_client)
8
+ ec2_client.stub_responses(:describe_tags, tags: tags)
9
+ end
10
+
11
+ context 'when instance metadata not available' do
12
+ it 'throws exception on timeout' do
13
+ allow(subject).to receive(:open).and_raise TimeoutError
14
+ expect { subject.detect_cluster }.to raise_error(TimeoutError)
15
+ end
16
+
17
+ it 'throws exception on connection refused' do
18
+ allow(subject).to receive(:open).and_raise Errno::ECONNREFUSED
19
+ expect { subject.detect_cluster }.to raise_error(Errno::ECONNREFUSED)
20
+ end
21
+
22
+ it 'throws exception on 404' do
23
+ allow(subject).to receive(:open).and_raise OpenURI::HTTPError.new('404', double('io'))
24
+ expect { subject.detect_cluster }.to raise_error(OpenURI::HTTPError)
25
+ end
26
+
27
+ it 'throws exception on network unreachable' do
28
+ allow(subject).to receive(:open).and_raise Errno::ENETUNREACH
29
+ expect { subject.detect_cluster }.to raise_error(Errno::ENETUNREACH)
30
+ end
31
+
32
+ end
33
+
34
+ shared_examples_for 'instance outside cluster' do
35
+ include_examples 'instance with metadata'
36
+
37
+ it 'returns a nil cluster name' do
38
+ expect(subject.detect_cluster[:cluster_name]).to be_nil
39
+ end
40
+
41
+ it 'returns a nil cluster role' do
42
+ expect(subject.detect_cluster[:cluster_name]).to be_nil
43
+ end
44
+ end
45
+
46
+ shared_examples_for 'instance with metadata' do
47
+ it 'returns the instance id' do
48
+ expect(subject.detect_cluster[:instance_id]).to eq instance_id
49
+ end
50
+
51
+ it 'returns the AWS region' do
52
+ expect(subject.detect_cluster[:region]).to eq region
53
+ end
54
+ end
55
+
56
+ context 'when instance metadata available' do
57
+ let (:instance_id) { 'i-00000001' }
58
+ let (:availability_zone) { 'eu-west-1c' }
59
+ let (:region) { 'eu-west-1' }
60
+
61
+ before :each do
62
+ stub_metadata('/placement/availability-zone', availability_zone)
63
+ stub_metadata('/instance-id', instance_id)
64
+ end
65
+
66
+ context 'when searching for EC2 instances' do
67
+ let (:ec2_client) { spy(Aws::EC2::Client) }
68
+
69
+ it 'creates AWS API client for the specified region' do
70
+ expect(Aws::EC2::Client).to receive(:new).with(region: region).once
71
+ subject.detect_cluster
72
+ end
73
+
74
+ it 'requests the tags for this instance' do
75
+ subject.detect_cluster
76
+ expect(ec2_client).to have_received(:describe_tags).once.with(filters: [{ name: 'resource-id', values: [instance_id] }])
77
+ end
78
+
79
+ end
80
+
81
+ context 'when AWS API throws exceptions' do
82
+ it 'bubbles exceptions to caller' do
83
+ ec2_client.stub_responses(:describe_tags, Aws::Errors::MissingCredentialsError)
84
+ expect { subject.detect_cluster }.to raise_error(Aws::Errors::MissingCredentialsError)
85
+ end
86
+ end
87
+
88
+ context 'when not part of cluster' do
89
+ context 'when instance has no tags' do
90
+ let (:tags) { [] }
91
+ include_examples 'instance outside cluster'
92
+ end
93
+
94
+ context 'when instance only has irrelevant tags' do
95
+ let (:tags) { [stub_tag('Name', 'anything')] }
96
+ include_examples 'instance outside cluster'
97
+ end
98
+
99
+ context 'when instance has no cloudfinder-cluster tag' do
100
+ let (:tags) { [stub_tag(Cloudfinder::EC2::ROLE_TAG_NAME, 'anything')] }
101
+ include_examples 'instance outside cluster'
102
+ end
103
+
104
+ context 'when instance has no cloudfinder-role tag' do
105
+ let (:tags) { [stub_tag(Cloudfinder::EC2::CLUSTER_TAG_NAME, 'anything')] }
106
+ include_examples 'instance outside cluster'
107
+ end
108
+ end
109
+
110
+ context 'when part of cluster' do
111
+ let (:cluster_name) { 'qa' }
112
+ let (:cluster_role) { 'app' }
113
+ let (:tags) { [
114
+ stub_tag(Cloudfinder::EC2::CLUSTER_TAG_NAME, cluster_name),
115
+ stub_tag(Cloudfinder::EC2::ROLE_TAG_NAME, cluster_role)
116
+ ] }
117
+
118
+ include_examples 'instance with metadata'
119
+
120
+ it 'returns cluster name' do
121
+ expect(subject.detect_cluster[:cluster_name]).to eq cluster_name
122
+ end
123
+ it 'returns cluster role' do
124
+ expect(subject.detect_cluster[:cluster_role]).to eq cluster_role.to_sym
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def stub_metadata(path, response)
131
+ url = "http://169.254.169.254/latest/meta-data#{path}"
132
+ timeout = Cloudfinder::EC2::Detector::EC2_METADATA_TIMEOUT
133
+ allow(subject).to receive(:open).with(url, { read_timeout: timeout }).and_return FakeResponse.new(response)
134
+ end
135
+
136
+ def stub_tag(key, value)
137
+ {
138
+ key: key,
139
+ value: value
140
+ }
141
+ end
142
+
143
+ class FakeResponse
144
+ def initialize(body)
145
+ @body = body
146
+ end
147
+
148
+ def read
149
+ @body
150
+ end
151
+ end
152
+ end