kitchen-ec2 0.8.0 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.cane +2 -1
- data/.gitignore +2 -0
- data/.rspec +3 -0
- data/.rubocop.yml +9 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +60 -17
- data/Gemfile +3 -3
- data/README.md +241 -85
- data/Rakefile +30 -12
- data/data/amis.json +18 -0
- data/kitchen-ec2.gemspec +21 -10
- data/lib/kitchen/driver/aws/client.rb +110 -0
- data/lib/kitchen/driver/aws/instance_generator.rb +148 -0
- data/lib/kitchen/driver/ec2.rb +288 -88
- data/lib/kitchen/driver/ec2_version.rb +1 -1
- data/spec/kitchen/driver/ec2/client_spec.rb +119 -0
- data/spec/kitchen/driver/ec2/instance_generator_spec.rb +303 -0
- data/spec/kitchen/driver/ec2_spec.rb +82 -0
- data/spec/spec_helper.rb +104 -0
- metadata +131 -22
- data/.tailor +0 -106
- data/spec/create_spec.rb +0 -106
data/Rakefile
CHANGED
@@ -1,21 +1,39 @@
|
|
1
|
-
|
2
|
-
require 'cane/rake_task'
|
3
|
-
require 'tailor/rake_task'
|
1
|
+
# -*- encoding: utf-8 -*-
|
4
2
|
|
5
|
-
|
6
|
-
Cane::RakeTask.new do |cane|
|
7
|
-
cane.canefile = './.cane'
|
8
|
-
end
|
3
|
+
require "bundler/gem_tasks"
|
9
4
|
|
10
|
-
|
5
|
+
require "rspec/core/rake_task"
|
6
|
+
RSpec::Core::RakeTask.new(:test)
|
11
7
|
|
12
8
|
desc "Display LOC stats"
|
13
9
|
task :stats do
|
14
10
|
puts "\n## Production Code Stats"
|
15
|
-
sh "countloc -r lib/kitchen"
|
11
|
+
sh "countloc -r lib/kitchen lib/kitchen.rb"
|
12
|
+
puts "\n## Test Code Stats"
|
13
|
+
sh "countloc -r spec features"
|
14
|
+
end
|
15
|
+
|
16
|
+
require "finstyle"
|
17
|
+
require "rubocop/rake_task"
|
18
|
+
RuboCop::RakeTask.new(:style) do |task|
|
19
|
+
task.options << "--display-cop-names"
|
20
|
+
end
|
21
|
+
|
22
|
+
if RUBY_ENGINE != "jruby"
|
23
|
+
require "cane/rake_task"
|
24
|
+
desc "Run cane to check quality metrics"
|
25
|
+
Cane::RakeTask.new do |cane|
|
26
|
+
cane.canefile = "./.cane"
|
27
|
+
end
|
28
|
+
|
29
|
+
desc "Run all quality tasks"
|
30
|
+
task :quality => [:cane, :style, :stats]
|
31
|
+
else
|
32
|
+
desc "Run all quality tasks"
|
33
|
+
task :quality => [:style, :stats]
|
16
34
|
end
|
17
35
|
|
18
|
-
|
19
|
-
|
36
|
+
require "yard"
|
37
|
+
YARD::Rake::YardocTask.new
|
20
38
|
|
21
|
-
task :default => [:quality]
|
39
|
+
task :default => [:test, :quality]
|
data/data/amis.json
CHANGED
@@ -5,6 +5,8 @@
|
|
5
5
|
"ubuntu-12.04": "ami-bb9a08ba",
|
6
6
|
"ubuntu-12.10": "ami-4556c544",
|
7
7
|
"ubuntu-13.04": "ami-97099a96",
|
8
|
+
"ubuntu-13.10": "ami-45f1a744",
|
9
|
+
"ubuntu-14.04": "ami-955c0c94",
|
8
10
|
"centos-6.4": "ami-9ffa709e",
|
9
11
|
"debian-7.1.0": "ami-f1f064f0"
|
10
12
|
},
|
@@ -13,6 +15,8 @@
|
|
13
15
|
"ubuntu-12.04": "ami-881c57da",
|
14
16
|
"ubuntu-12.10": "ami-3ce0a86e",
|
15
17
|
"ubuntu-13.04": "ami-4af5bd18",
|
18
|
+
"ubuntu-13.10": "ami-02e6b850",
|
19
|
+
"ubuntu-14.04": "ami-9a7c25c8",
|
16
20
|
"centos-6.4": "ami-46f5bb14",
|
17
21
|
"debian-7.1.0": "ami-fe8ac3ac"
|
18
22
|
},
|
@@ -21,6 +25,8 @@
|
|
21
25
|
"ubuntu-12.04": "ami-cb26b4f1",
|
22
26
|
"ubuntu-12.10": "ami-1703912d",
|
23
27
|
"ubuntu-13.04": "ami-8f32a0b5",
|
28
|
+
"ubuntu-13.10": "ami-e54423df",
|
29
|
+
"ubuntu-14.04": "ami-f3b1d6c9",
|
24
30
|
"centos-6.4": "ami-9352c1a9",
|
25
31
|
"debian-7.1.0": "ami-4e099a74"
|
26
32
|
},
|
@@ -29,6 +35,8 @@
|
|
29
35
|
"ubuntu-12.04": "ami-d3b5aea7",
|
30
36
|
"ubuntu-12.10": "ami-6b62791f",
|
31
37
|
"ubuntu-13.04": "ami-6fd4cf1b",
|
38
|
+
"ubuntu-13.10": "ami-39eb3f4e",
|
39
|
+
"ubuntu-14.04": "ami-c112c5b6",
|
32
40
|
"centos-6.4": "ami-75190b01",
|
33
41
|
"debian-7.1.0": "ami-954559e1"
|
34
42
|
},
|
@@ -37,6 +45,8 @@
|
|
37
45
|
"ubuntu-12.04": "ami-c905a1d4",
|
38
46
|
"ubuntu-12.10": "ami-e759fdfa",
|
39
47
|
"ubuntu-13.04": "ami-4b2b8f56",
|
48
|
+
"ubuntu-13.10": "ami-076cc21a",
|
49
|
+
"ubuntu-14.04": "ami-052b8518",
|
40
50
|
"centos-6.4": "ami-a665c0bb",
|
41
51
|
"debian-7.1.0": "ami-b03590ad"
|
42
52
|
},
|
@@ -45,6 +55,8 @@
|
|
45
55
|
"ubuntu-12.04": "ami-2f115c46",
|
46
56
|
"ubuntu-12.10": "ami-4d5b1824",
|
47
57
|
"ubuntu-13.04": "ami-a73371ce",
|
58
|
+
"ubuntu-13.10": "ami-a65393ce",
|
59
|
+
"ubuntu-14.04": "ami-4a915c22",
|
48
60
|
"centos-6.4": "ami-bf5021d6",
|
49
61
|
"debian-7.1.0": "ami-50d9a439"
|
50
62
|
},
|
@@ -53,6 +65,8 @@
|
|
53
65
|
"ubuntu-12.04": "ami-eaf0daaf",
|
54
66
|
"ubuntu-12.10": "ami-02220847",
|
55
67
|
"ubuntu-13.04": "ami-fe052fbb",
|
68
|
+
"ubuntu-13.10": "ami-bb2a2afe",
|
69
|
+
"ubuntu-14.04": "ami-d99a9a9c",
|
56
70
|
"centos-6.4": "ami-5d456c18",
|
57
71
|
"debian-7.1.0": "ami-1a9bb25f"
|
58
72
|
},
|
@@ -61,6 +75,8 @@
|
|
61
75
|
"ubuntu-12.04": "ami-e6f36fd6",
|
62
76
|
"ubuntu-12.10": "ami-0c069b3c",
|
63
77
|
"ubuntu-13.04": "ami-4ade427a",
|
78
|
+
"ubuntu-13.10": "ami-c18ff1f1",
|
79
|
+
"ubuntu-14.04": "ami-b7720b87",
|
64
80
|
"centos-6.4": "ami-b3bf2f83",
|
65
81
|
"debian-7.1.0": "ami-158a1925"
|
66
82
|
}
|
@@ -70,6 +86,8 @@
|
|
70
86
|
"ubuntu-12.04": "ubuntu",
|
71
87
|
"ubuntu-12.10": "ubuntu",
|
72
88
|
"ubuntu-13.04": "ubuntu",
|
89
|
+
"ubuntu-13.10": "ubuntu",
|
90
|
+
"ubuntu-14.04": "ubuntu",
|
73
91
|
"centos-6.4": "root",
|
74
92
|
"debian-7.1.0": "admin"
|
75
93
|
},
|
data/kitchen-ec2.gemspec
CHANGED
@@ -1,28 +1,39 @@
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
2
|
-
lib = File.expand_path(
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require
|
4
|
+
require "kitchen/driver/ec2_version.rb"
|
5
5
|
|
6
6
|
Gem::Specification.new do |gem|
|
7
7
|
gem.name = "kitchen-ec2"
|
8
8
|
gem.version = Kitchen::Driver::EC2_VERSION
|
9
|
-
gem.license =
|
9
|
+
gem.license = "Apache 2.0"
|
10
10
|
gem.authors = ["Fletcher Nichol"]
|
11
11
|
gem.email = ["fnichol@nichol.ca"]
|
12
12
|
gem.description = "A Test Kitchen Driver for Amazon EC2"
|
13
13
|
gem.summary = gem.description
|
14
14
|
gem.homepage = "http://kitchen.ci/"
|
15
15
|
|
16
|
-
gem.files = `git ls-files`.split(
|
16
|
+
gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
17
17
|
gem.executables = []
|
18
18
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
19
19
|
gem.require_paths = ["lib"]
|
20
20
|
|
21
|
-
gem.add_dependency
|
22
|
-
gem.add_dependency
|
21
|
+
gem.add_dependency "test-kitchen", "~> 1.4"
|
22
|
+
gem.add_dependency "excon"
|
23
|
+
gem.add_dependency "multi_json"
|
24
|
+
gem.add_dependency "aws-sdk-v1", "~> 1.59.0"
|
25
|
+
gem.add_dependency "aws-sdk", "~> 2"
|
23
26
|
|
24
|
-
gem.add_development_dependency
|
25
|
-
gem.add_development_dependency
|
26
|
-
gem.add_development_dependency
|
27
|
-
gem.add_development_dependency
|
27
|
+
gem.add_development_dependency "rspec", "~> 3.2"
|
28
|
+
gem.add_development_dependency "countloc", "~> 0.4"
|
29
|
+
gem.add_development_dependency "maruku", "~> 0.6"
|
30
|
+
gem.add_development_dependency "simplecov", "~> 0.7"
|
31
|
+
gem.add_development_dependency "yard", "~> 0.8"
|
32
|
+
|
33
|
+
# style and complexity libraries are tightly version pinned as newer releases
|
34
|
+
# may introduce new and undesireable style choices which would be immediately
|
35
|
+
# enforced in CI
|
36
|
+
gem.add_development_dependency "finstyle", "1.4.0"
|
37
|
+
gem.add_development_dependency "cane", "2.6.2"
|
38
|
+
gem.add_development_dependency "climate_control"
|
28
39
|
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
#
|
3
|
+
# Author:: Tyler Ball (<tball@chef.io>)
|
4
|
+
#
|
5
|
+
# Copyright (C) 2015, Fletcher Nichol
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
#
|
13
|
+
# Unless required by applicable law or agreed to in writing, software
|
14
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
15
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
16
|
+
# See the License for the specific language governing permissions and
|
17
|
+
# limitations under the License.
|
18
|
+
|
19
|
+
require "aws-sdk"
|
20
|
+
require "aws-sdk-core/credentials"
|
21
|
+
require "aws-sdk-core/shared_credentials"
|
22
|
+
require "aws-sdk-core/instance_profile_credentials"
|
23
|
+
|
24
|
+
module Kitchen
|
25
|
+
|
26
|
+
module Driver
|
27
|
+
|
28
|
+
class Aws
|
29
|
+
|
30
|
+
# A class for creating and managing the EC2 client connection
|
31
|
+
#
|
32
|
+
# @author Tyler Ball <tball@chef.io>
|
33
|
+
class Client
|
34
|
+
|
35
|
+
def initialize(
|
36
|
+
region,
|
37
|
+
profile_name = nil,
|
38
|
+
access_key_id = nil,
|
39
|
+
secret_access_key = nil,
|
40
|
+
session_token = nil
|
41
|
+
)
|
42
|
+
creds = self.class.get_credentials(
|
43
|
+
profile_name, access_key_id, secret_access_key, session_token
|
44
|
+
)
|
45
|
+
::Aws.config.update(
|
46
|
+
:region => region,
|
47
|
+
:credentials => creds
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Try and get the credentials from an ordered list of locations
|
52
|
+
# http://docs.aws.amazon.com/sdkforruby/api/index.html#Configuration
|
53
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
54
|
+
def self.get_credentials(profile_name, access_key_id, secret_access_key, session_token)
|
55
|
+
shared_creds = ::Aws::SharedCredentials.new(:profile_name => profile_name)
|
56
|
+
if access_key_id && secret_access_key
|
57
|
+
::Aws::Credentials.new(access_key_id, secret_access_key, session_token)
|
58
|
+
# TODO: these are deprecated, remove them in the next major version
|
59
|
+
elsif ENV["AWS_ACCESS_KEY"] && ENV["AWS_SECRET_KEY"]
|
60
|
+
::Aws::Credentials.new(
|
61
|
+
ENV["AWS_ACCESS_KEY"],
|
62
|
+
ENV["AWS_SECRET_KEY"],
|
63
|
+
ENV["AWS_TOKEN"]
|
64
|
+
)
|
65
|
+
elsif ENV["AWS_ACCESS_KEY_ID"] && ENV["AWS_SECRET_ACCESS_KEY"]
|
66
|
+
::Aws::Credentials.new(
|
67
|
+
ENV["AWS_ACCESS_KEY_ID"],
|
68
|
+
ENV["AWS_SECRET_ACCESS_KEY"],
|
69
|
+
ENV["AWS_SESSION_TOKEN"]
|
70
|
+
)
|
71
|
+
elsif shared_creds.loadable?
|
72
|
+
shared_creds
|
73
|
+
else
|
74
|
+
::Aws::InstanceProfileCredentials.new(:retries => 1)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
78
|
+
|
79
|
+
def create_instance(options)
|
80
|
+
resource.create_instances(options)[0]
|
81
|
+
end
|
82
|
+
|
83
|
+
def get_instance(id)
|
84
|
+
resource.instance(id)
|
85
|
+
end
|
86
|
+
|
87
|
+
def get_instance_from_spot_request(request_id)
|
88
|
+
resource.instances(
|
89
|
+
:filters => [{
|
90
|
+
:name => "spot-instance-request-id",
|
91
|
+
:values => [request_id]
|
92
|
+
}]
|
93
|
+
).to_a[0]
|
94
|
+
end
|
95
|
+
|
96
|
+
def client
|
97
|
+
@client ||= ::Aws::EC2::Client.new
|
98
|
+
end
|
99
|
+
|
100
|
+
def resource
|
101
|
+
@resource ||= ::Aws::EC2::Resource.new
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
#
|
3
|
+
# Author:: Tyler Ball (<tball@chef.io>)
|
4
|
+
#
|
5
|
+
# Copyright (C) 2015, Fletcher Nichol
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
#
|
13
|
+
# Unless required by applicable law or agreed to in writing, software
|
14
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
15
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
16
|
+
# See the License for the specific language governing permissions and
|
17
|
+
# limitations under the License.
|
18
|
+
|
19
|
+
require "kitchen/logging"
|
20
|
+
|
21
|
+
module Kitchen
|
22
|
+
|
23
|
+
module Driver
|
24
|
+
|
25
|
+
class Aws
|
26
|
+
|
27
|
+
# A class for encapsulating the instance payload logic
|
28
|
+
#
|
29
|
+
# @author Tyler Ball <tball@chef.io>
|
30
|
+
class InstanceGenerator
|
31
|
+
|
32
|
+
include Logging
|
33
|
+
|
34
|
+
attr_reader :config, :ec2
|
35
|
+
|
36
|
+
def initialize(config, ec2)
|
37
|
+
@config = config
|
38
|
+
@ec2 = ec2
|
39
|
+
end
|
40
|
+
|
41
|
+
# Transform the provided config into the hash to send to AWS. Some fields
|
42
|
+
# can be passed in null, others need to be ommitted if they are null
|
43
|
+
def ec2_instance_data # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
44
|
+
i = {
|
45
|
+
:placement => {
|
46
|
+
:availability_zone => config[:availability_zone]
|
47
|
+
},
|
48
|
+
:instance_type => config[:instance_type],
|
49
|
+
:ebs_optimized => config[:ebs_optimized],
|
50
|
+
:image_id => config[:image_id],
|
51
|
+
:key_name => config[:aws_ssh_key_id],
|
52
|
+
:subnet_id => config[:subnet_id],
|
53
|
+
:private_ip_address => config[:private_ip_address]
|
54
|
+
}
|
55
|
+
i[:block_device_mappings] = block_device_mappings unless block_device_mappings.empty?
|
56
|
+
i[:security_group_ids] = config[:security_group_ids] if config[:security_group_ids]
|
57
|
+
i[:user_data] = prepared_user_data if prepared_user_data
|
58
|
+
if config[:iam_instance_profile]
|
59
|
+
i[:iam_instance_profile] = { :name => config[:iam_profile_name] }
|
60
|
+
end
|
61
|
+
if !config.fetch(:associate_public_ip_address, nil).nil?
|
62
|
+
i[:network_interfaces] =
|
63
|
+
[{
|
64
|
+
:device_index => 0,
|
65
|
+
:associate_public_ip_address => config[:associate_public_ip_address]
|
66
|
+
}]
|
67
|
+
end
|
68
|
+
i
|
69
|
+
end
|
70
|
+
|
71
|
+
# Transforms the provided config into the appropriate hash for creating a BDM
|
72
|
+
# in AWS
|
73
|
+
def block_device_mappings # rubocop:disable all
|
74
|
+
return @bdms if @bdms
|
75
|
+
bdms = config[:block_device_mappings] || []
|
76
|
+
if bdms.empty?
|
77
|
+
if config[:ebs_volume_size] || config.fetch(:ebs_delete_on_termination, nil) ||
|
78
|
+
config[:ebs_device_name] || config[:ebs_volume_type]
|
79
|
+
# If the user didn't supply block_device_mappings but did supply
|
80
|
+
# the old configs, copy them into the block_device_mappings array correctly
|
81
|
+
# TODO: remove this logic when we remove the deprecated values
|
82
|
+
bdms << {
|
83
|
+
:ebs_volume_size => config[:ebs_volume_size] || 8,
|
84
|
+
:ebs_delete_on_termination => config.fetch(:ebs_delete_on_termination, true),
|
85
|
+
:ebs_device_name => config[:ebs_device_name] || "/dev/sda1",
|
86
|
+
:ebs_volume_type => config[:ebs_volume_type] || "standard"
|
87
|
+
}
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Convert the provided keys to what AWS expects
|
92
|
+
bdms = bdms.map do |bdm|
|
93
|
+
b = {
|
94
|
+
:ebs => {
|
95
|
+
:volume_size => bdm[:ebs_volume_size],
|
96
|
+
:delete_on_termination => bdm[:ebs_delete_on_termination]
|
97
|
+
},
|
98
|
+
:device_name => bdm[:ebs_device_name]
|
99
|
+
}
|
100
|
+
b[:ebs][:volume_type] = bdm[:ebs_volume_type] if bdm[:ebs_volume_type]
|
101
|
+
b[:ebs][:snapshot_id] = bdm[:ebs_snapshot_id] if bdm[:ebs_snapshot_id]
|
102
|
+
b[:virtual_name] = bdm[:ebs_virtual_name] if bdm[:ebs_virtual_name]
|
103
|
+
b
|
104
|
+
end
|
105
|
+
|
106
|
+
debug_if_root_device(bdms)
|
107
|
+
|
108
|
+
@bdms = bdms
|
109
|
+
end
|
110
|
+
|
111
|
+
# If the provided bdms match the root device in the AMI, emit log that
|
112
|
+
# states this
|
113
|
+
def debug_if_root_device(bdms)
|
114
|
+
image_id = config[:image_id]
|
115
|
+
image = ec2.resource.image(image_id)
|
116
|
+
begin
|
117
|
+
root_device_name = image.root_device_name
|
118
|
+
rescue ::Aws::EC2::Errors::InvalidAMIIDNotFound
|
119
|
+
# Not raising here because AWS will give a more meaningful message
|
120
|
+
# when we try to create the instance
|
121
|
+
return
|
122
|
+
end
|
123
|
+
bdms.find { |bdm|
|
124
|
+
if bdm[:device_name] == root_device_name
|
125
|
+
info("Overriding root device [#{root_device_name}] from image [#{image_id}]")
|
126
|
+
end
|
127
|
+
}
|
128
|
+
end
|
129
|
+
|
130
|
+
def prepared_user_data
|
131
|
+
# If user_data is a file reference, lets read it as such
|
132
|
+
unless @user_data
|
133
|
+
if config[:user_data] && File.file?(config[:user_data])
|
134
|
+
@user_data = File.read(config[:user_data])
|
135
|
+
else
|
136
|
+
@user_data = config[:user_data]
|
137
|
+
end
|
138
|
+
end
|
139
|
+
@user_data
|
140
|
+
end
|
141
|
+
|
142
|
+
end
|
143
|
+
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
data/lib/kitchen/driver/ec2.rb
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
#
|
3
3
|
# Author:: Fletcher Nichol (<fnichol@nichol.ca>)
|
4
4
|
#
|
5
|
-
# Copyright (C)
|
5
|
+
# Copyright (C) 2015, Fletcher Nichol
|
6
6
|
#
|
7
7
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
8
|
# you may not use this file except in compliance with the License.
|
@@ -16,10 +16,13 @@
|
|
16
16
|
# See the License for the specific language governing permissions and
|
17
17
|
# limitations under the License.
|
18
18
|
|
19
|
-
require
|
20
|
-
require
|
21
|
-
require
|
22
|
-
require
|
19
|
+
require "benchmark"
|
20
|
+
require "json"
|
21
|
+
require "aws"
|
22
|
+
require "kitchen"
|
23
|
+
require "kitchen/driver/ec2_version"
|
24
|
+
require_relative "aws/client"
|
25
|
+
require_relative "aws/instance_generator"
|
23
26
|
|
24
27
|
module Kitchen
|
25
28
|
|
@@ -28,143 +31,340 @@ module Kitchen
|
|
28
31
|
# Amazon EC2 driver for Test Kitchen.
|
29
32
|
#
|
30
33
|
# @author Fletcher Nichol <fnichol@nichol.ca>
|
31
|
-
class Ec2 < Kitchen::Driver::
|
34
|
+
class Ec2 < Kitchen::Driver::Base # rubocop:disable Metrics/ClassLength
|
32
35
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
+
kitchen_driver_api_version 2
|
37
|
+
|
38
|
+
plugin_version Kitchen::Driver::EC2_VERSION
|
39
|
+
|
40
|
+
default_config :region, ENV["AWS_REGION"] || "us-east-1"
|
41
|
+
default_config :shared_credentials_profile, nil
|
42
|
+
default_config :availability_zone, nil
|
43
|
+
default_config :flavor_id, nil
|
44
|
+
default_config :instance_type, nil
|
36
45
|
default_config :ebs_optimized, false
|
37
|
-
default_config :security_group_ids,
|
38
|
-
default_config :tags,
|
39
|
-
default_config :
|
40
|
-
|
46
|
+
default_config :security_group_ids, nil
|
47
|
+
default_config :tags, "created-by" => "test-kitchen"
|
48
|
+
default_config :user_data, nil
|
49
|
+
default_config :private_ip_address, nil
|
50
|
+
default_config :iam_profile_name, nil
|
51
|
+
default_config :price, nil
|
52
|
+
default_config :retryable_tries, 60
|
53
|
+
default_config :retryable_sleep, 5
|
54
|
+
default_config :aws_access_key_id, nil
|
55
|
+
default_config :aws_secret_access_key, nil
|
56
|
+
default_config :aws_session_token, nil
|
57
|
+
default_config :aws_ssh_key_id, ENV["AWS_SSH_KEY_ID"]
|
58
|
+
default_config :image_id do |driver|
|
59
|
+
driver.default_ami
|
41
60
|
end
|
42
|
-
default_config :
|
43
|
-
|
61
|
+
default_config :username, nil
|
62
|
+
default_config :associate_public_ip, nil
|
63
|
+
|
64
|
+
required_config :aws_ssh_key_id
|
65
|
+
required_config :image_id
|
66
|
+
|
67
|
+
def self.validation_warn(driver, old_key, new_key)
|
68
|
+
driver.warn "WARN: The driver[#{driver.class.name}] config key `#{old_key}` " \
|
69
|
+
"is deprecated, please use `#{new_key}`"
|
44
70
|
end
|
45
|
-
|
46
|
-
|
71
|
+
|
72
|
+
# TODO: remove these in the next major version of TK
|
73
|
+
deprecated_configs = [:ebs_volume_size, :ebs_delete_on_termination, :ebs_device_name]
|
74
|
+
deprecated_configs.each do |d|
|
75
|
+
validations[d] = lambda do |attr, val, driver|
|
76
|
+
unless val.nil?
|
77
|
+
validation_warn(driver, attr, "block_device_mappings")
|
78
|
+
end
|
79
|
+
end
|
47
80
|
end
|
48
|
-
|
49
|
-
|
81
|
+
validations[:ssh_key] = lambda do |attr, val, driver|
|
82
|
+
unless val.nil?
|
83
|
+
validation_warn(driver, attr, "transport.ssh_key")
|
84
|
+
end
|
50
85
|
end
|
51
|
-
|
52
|
-
|
86
|
+
validations[:ssh_timeout] = lambda do |attr, val, driver|
|
87
|
+
unless val.nil?
|
88
|
+
validation_warn(driver, attr, "transport.connection_timeout")
|
89
|
+
end
|
53
90
|
end
|
54
|
-
|
55
|
-
|
91
|
+
validations[:ssh_retries] = lambda do |attr, val, driver|
|
92
|
+
unless val.nil?
|
93
|
+
validation_warn(driver, attr, "transport.connection_retries")
|
94
|
+
end
|
56
95
|
end
|
57
|
-
|
58
|
-
|
96
|
+
validations[:username] = lambda do |attr, val, driver|
|
97
|
+
unless val.nil?
|
98
|
+
validation_warn(driver, attr, "transport.username")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
validations[:flavor_id] = lambda do |attr, val, driver|
|
102
|
+
unless val.nil?
|
103
|
+
validation_warn(driver, attr, "instance_type")
|
104
|
+
end
|
59
105
|
end
|
60
106
|
|
61
|
-
default_config :
|
107
|
+
default_config :block_device_mappings, nil
|
108
|
+
validations[:block_device_mappings] = lambda do |_attr, val, _driver|
|
109
|
+
unless val.nil?
|
110
|
+
val.each do |bdm|
|
111
|
+
unless bdm.keys.include?(:ebs_volume_size) &&
|
112
|
+
bdm.keys.include?(:ebs_delete_on_termination) &&
|
113
|
+
bdm.keys.include?(:ebs_device_name)
|
114
|
+
raise "Every :block_device_mapping must include the keys :ebs_volume_size, " \
|
115
|
+
":ebs_delete_on_termination and :ebs_device_name"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
62
120
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
121
|
+
# The access key/secret are now using the priority list AWS uses
|
122
|
+
# Providing these inside the .kitchen.yml is no longer recommended
|
123
|
+
validations[:aws_access_key_id] = lambda do |attr, val, driver|
|
124
|
+
unless val.nil?
|
125
|
+
driver.warn "WARN: #{attr} has been deprecated, please use " \
|
126
|
+
"ENV['AWS_ACCESS_KEY_ID'] or ~/.aws/credentials. See " \
|
127
|
+
"the README for more details"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
validations[:aws_secret_access_key] = lambda do |attr, val, driver|
|
131
|
+
unless val.nil?
|
132
|
+
driver.warn "WARN: #{attr} has been deprecated, please use " \
|
133
|
+
"ENV['AWS_SECRET_ACCESS_KEY'] or ~/.aws/credentials. See " \
|
134
|
+
"the README for more details"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
validations[:aws_session_token] = lambda do |attr, val, driver|
|
138
|
+
unless val.nil?
|
139
|
+
driver.warn "WARN: #{attr} has been deprecated, please use " \
|
140
|
+
"ENV['AWS_SESSION_TOKEN'] or ~/.aws/credentials. See " \
|
141
|
+
"the README for more details"
|
142
|
+
end
|
143
|
+
end
|
67
144
|
|
68
|
-
|
69
|
-
|
70
|
-
|
145
|
+
# A lifecycle method that should be invoked when the object is about
|
146
|
+
# ready to be used. A reference to an Instance is required as
|
147
|
+
# configuration dependant data may be access through an Instance. This
|
148
|
+
# also acts as a hook point where the object may wish to perform other
|
149
|
+
# last minute checks, validations, or configuration expansions.
|
150
|
+
#
|
151
|
+
# @param instance [Instance] an associated instance
|
152
|
+
# @return [self] itself, for use in chaining
|
153
|
+
# @raise [ClientError] if instance parameter is nil
|
154
|
+
def finalize_config!(instance)
|
155
|
+
super
|
71
156
|
|
157
|
+
if config[:availability_zone].nil?
|
158
|
+
config[:availability_zone] = config[:region] + "b"
|
159
|
+
end
|
160
|
+
# TODO: when we get rid of flavor_id, move this to a default
|
161
|
+
if config[:instance_type].nil?
|
162
|
+
config[:instance_type] = config[:flavor_id] || "m1.small"
|
163
|
+
end
|
164
|
+
|
165
|
+
self
|
166
|
+
end
|
167
|
+
|
168
|
+
def create(state) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
169
|
+
copy_deprecated_configs(state)
|
170
|
+
return if state[:server_id]
|
171
|
+
|
172
|
+
info(Kitchen::Util.outdent!(<<-END))
|
173
|
+
Creating <#{state[:server_id]}>...
|
174
|
+
If you are not using an account that qualifies under the AWS
|
175
|
+
free-tier, you may be charged to run these suites. The charge
|
176
|
+
should be minimal, but neither Test Kitchen nor its maintainers
|
177
|
+
are responsible for your incurred costs.
|
178
|
+
END
|
179
|
+
|
180
|
+
if config[:price]
|
181
|
+
# Spot instance when a price is set
|
182
|
+
server = submit_spot(state)
|
183
|
+
else
|
184
|
+
# On-demand instance
|
185
|
+
server = submit_server
|
186
|
+
end
|
187
|
+
info("Instance <#{server.id}> requested.")
|
188
|
+
tag_server(server)
|
189
|
+
|
190
|
+
state[:server_id] = server.id
|
72
191
|
info("EC2 instance <#{state[:server_id]}> created.")
|
73
|
-
|
74
|
-
|
192
|
+
wait_log = proc do |attempts|
|
193
|
+
c = attempts * config[:retryable_sleep]
|
194
|
+
t = config[:retryable_tries] * config[:retryable_sleep]
|
195
|
+
info "Waited #{c}/#{t}s for instance <#{state[:server_id]}> to become ready."
|
196
|
+
end
|
197
|
+
server = server.wait_until(
|
198
|
+
:max_attempts => config[:retryable_tries],
|
199
|
+
:delay => config[:retryable_sleep],
|
200
|
+
:before_attempt => wait_log
|
201
|
+
) do |s|
|
202
|
+
hostname = hostname(s)
|
203
|
+
# Euca instances often report ready before they have an IP
|
204
|
+
s.state.name == "running" && !hostname.nil? && hostname != "0.0.0.0"
|
205
|
+
end
|
206
|
+
|
207
|
+
info("EC2 instance <#{state[:server_id]}> ready.")
|
75
208
|
state[:hostname] = hostname(server)
|
76
|
-
|
77
|
-
|
209
|
+
instance.transport.connection(state).wait_until_ready
|
210
|
+
create_ec2_json(state)
|
78
211
|
debug("ec2:create '#{state[:hostname]}'")
|
79
|
-
rescue Fog::Errors::Error, Excon::Errors::Error => ex
|
80
|
-
raise ActionFailed, ex.message
|
81
212
|
end
|
82
213
|
|
83
214
|
def destroy(state)
|
84
215
|
return if state[:server_id].nil?
|
85
216
|
|
86
|
-
server =
|
87
|
-
|
217
|
+
server = ec2.get_instance(state[:server_id])
|
218
|
+
unless server.nil?
|
219
|
+
instance.transport.connection(state).close
|
220
|
+
server.terminate unless server.nil?
|
221
|
+
end
|
222
|
+
if state[:spot_request_id]
|
223
|
+
debug("Deleting spot request <#{state[:server_id]}>")
|
224
|
+
ec2.client.cancel_spot_instance_requests(
|
225
|
+
:spot_instance_request_ids => [state[:spot_request_id]]
|
226
|
+
)
|
227
|
+
end
|
88
228
|
info("EC2 instance <#{state[:server_id]}> destroyed.")
|
89
229
|
state.delete(:server_id)
|
90
230
|
state.delete(:hostname)
|
91
231
|
end
|
92
232
|
|
93
233
|
def default_ami
|
94
|
-
region = amis[
|
234
|
+
region = amis["regions"][config[:region]]
|
95
235
|
region && region[instance.platform.name]
|
96
236
|
end
|
97
237
|
|
98
|
-
def default_username
|
99
|
-
amis['usernames'][instance.platform.name] || 'root'
|
100
|
-
end
|
101
|
-
|
102
238
|
private
|
103
239
|
|
104
|
-
def
|
105
|
-
|
106
|
-
:
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
:endpoint => config[:endpoint],
|
240
|
+
def ec2
|
241
|
+
@ec2 ||= Aws::Client.new(
|
242
|
+
config[:region],
|
243
|
+
config[:shared_credentials_profile],
|
244
|
+
config[:aws_access_key_id],
|
245
|
+
config[:aws_secret_access_key],
|
246
|
+
config[:aws_session_token]
|
112
247
|
)
|
113
248
|
end
|
114
249
|
|
115
|
-
def
|
116
|
-
|
250
|
+
def instance_generator
|
251
|
+
@instance_generator ||= Aws::InstanceGenerator.new(config, ec2)
|
252
|
+
end
|
117
253
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
:
|
126
|
-
|
127
|
-
|
254
|
+
# This copies transport config from the current config object into the
|
255
|
+
# state. This relies on logic in the transport that merges the transport
|
256
|
+
# config with the current state object, so its a bad coupling. But we
|
257
|
+
# can get rid of this when we get rid of these deprecated configs!
|
258
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
259
|
+
def copy_deprecated_configs(state)
|
260
|
+
if config[:ssh_timeout]
|
261
|
+
state[:connection_timeout] = config[:ssh_timeout]
|
262
|
+
end
|
263
|
+
if config[:ssh_retries]
|
264
|
+
state[:connection_retries] = config[:ssh_retries]
|
265
|
+
end
|
266
|
+
if config[:username]
|
267
|
+
state[:username] = config[:username]
|
268
|
+
elsif instance.transport[:username] == instance.transport.class.defaults[:username]
|
269
|
+
# If the transport has the default username, copy it from amis.json
|
270
|
+
# This duplicated old behavior but I hate amis.json
|
271
|
+
ami_username = amis["usernames"][instance.platform.name]
|
272
|
+
state[:username] = ami_username if ami_username
|
273
|
+
end
|
274
|
+
if config[:ssh_key]
|
275
|
+
state[:ssh_key] = config[:ssh_key]
|
276
|
+
end
|
128
277
|
end
|
278
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
129
279
|
|
130
|
-
|
131
|
-
|
132
|
-
debug("
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
280
|
+
# Fog AWS helper for creating the instance
|
281
|
+
def submit_server
|
282
|
+
debug("Creating EC2 Instance..")
|
283
|
+
instance_data = instance_generator.ec2_instance_data
|
284
|
+
instance_data[:min_count] = 1
|
285
|
+
instance_data[:max_count] = 1
|
286
|
+
ec2.create_instance(instance_data)
|
287
|
+
end
|
288
|
+
|
289
|
+
def submit_spot(state) # rubocop:disable Metrics/AbcSize
|
290
|
+
debug("Creating EC2 Spot Instance..")
|
291
|
+
request_data = {}
|
292
|
+
request_data[:spot_price] = config[:price].to_s
|
293
|
+
request_data[:launch_specification] = instance_generator.ec2_instance_data
|
294
|
+
|
295
|
+
response = ec2.client.request_spot_instances(request_data)
|
296
|
+
spot_request_id = response[:spot_instance_requests][0][:spot_instance_request_id]
|
297
|
+
# deleting the instance cancels the request, but deleting the request
|
298
|
+
# does not affect the instance
|
299
|
+
state[:spot_request_id] = spot_request_id
|
300
|
+
ec2.client.wait_until(
|
301
|
+
:spot_instance_request_fulfilled,
|
302
|
+
:spot_instance_request_ids => [spot_request_id]
|
303
|
+
) do |w|
|
304
|
+
w.max_attempts = config[:retryable_tries]
|
305
|
+
w.delay = config[:retryable_sleep]
|
306
|
+
w.before_attempt do |attempts|
|
307
|
+
c = attempts * config[:retryable_sleep]
|
308
|
+
t = config[:retryable_tries] * config[:retryable_sleep]
|
309
|
+
info "Waited #{c}/#{t}s for spot request <#{spot_request_id}> to become fulfilled."
|
310
|
+
end
|
311
|
+
end
|
312
|
+
ec2.get_instance_from_spot_request(spot_request_id)
|
313
|
+
end
|
314
|
+
|
315
|
+
def tag_server(server)
|
316
|
+
tags = []
|
317
|
+
config[:tags].each do |k, v|
|
318
|
+
tags << { :key => k, :value => v }
|
319
|
+
end
|
320
|
+
server.create_tags(:tags => tags)
|
140
321
|
end
|
141
322
|
|
142
323
|
def amis
|
143
324
|
@amis ||= begin
|
144
325
|
json_file = File.join(File.dirname(__FILE__),
|
145
|
-
%w
|
326
|
+
%w[.. .. .. data amis.json])
|
146
327
|
JSON.load(IO.read(json_file))
|
147
328
|
end
|
148
329
|
end
|
149
330
|
|
150
|
-
|
331
|
+
#
|
332
|
+
# Ordered mapping from config name to Fog name. Ordered by preference
|
333
|
+
# when looking up hostname.
|
334
|
+
#
|
335
|
+
INTERFACE_TYPES =
|
151
336
|
{
|
152
|
-
|
153
|
-
|
154
|
-
|
337
|
+
"dns" => "public_dns_name",
|
338
|
+
"public" => "public_ip_address",
|
339
|
+
"private" => "private_ip_address"
|
155
340
|
}
|
156
|
-
end
|
157
341
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
342
|
+
#
|
343
|
+
# Lookup hostname of provided server. If interface_type is provided use
|
344
|
+
# that interface to lookup hostname. Otherwise, try ordered list of
|
345
|
+
# options.
|
346
|
+
#
|
347
|
+
def hostname(server, interface_type = nil)
|
348
|
+
if interface_type
|
349
|
+
interface_type = INTERFACE_TYPES.fetch(interface_type) do
|
350
|
+
raise Kitchen::UserError, "Invalid interface [#{interface_type}]"
|
162
351
|
end
|
163
|
-
server.send(
|
352
|
+
server.send(interface_type)
|
164
353
|
else
|
165
|
-
|
354
|
+
potential_hostname = nil
|
355
|
+
INTERFACE_TYPES.values.each do |type|
|
356
|
+
potential_hostname ||= server.send(type)
|
357
|
+
end
|
358
|
+
potential_hostname
|
166
359
|
end
|
167
360
|
end
|
361
|
+
|
362
|
+
def create_ec2_json(state)
|
363
|
+
instance.transport.connection(state).execute(
|
364
|
+
"sudo mkdir -p /etc/chef/ohai/hints;sudo touch /etc/chef/ohai/hints/ec2.json"
|
365
|
+
)
|
366
|
+
end
|
367
|
+
|
168
368
|
end
|
169
369
|
end
|
170
370
|
end
|