kitchen-linode 0.14.0 → 0.15.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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +38 -0
- data/.gitignore +1 -0
- data/.kitchen.yml +68 -0
- data/.rubocop.yml +2 -1155
- data/Gemfile +3 -1
- data/README.md +75 -67
- data/Rakefile +14 -10
- data/kitchen-linode.gemspec +21 -17
- data/lib/kitchen/driver/linode.rb +236 -193
- data/lib/kitchen/driver/linode_version.rb +1 -2
- data/spec/kitchen/driver/linode_spec.rb +258 -94
- data/spec/mocks/create.txt +67 -0
- data/spec/mocks/create_bad.txt +26 -0
- data/spec/mocks/create_ratelimit.txt +26 -0
- data/spec/mocks/create_timeout.txt +25 -0
- data/spec/mocks/delete.txt +27 -0
- data/spec/mocks/list.txt +34 -0
- data/spec/mocks/view.txt +67 -0
- data/spec/spec_helper.rb +39 -2
- metadata +106 -25
- data/.tailor +0 -4
- data/.travis.yml +0 -11
@@ -1,4 +1,3 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
1
|
#
|
3
2
|
# Author:: Brett Taylor (<btaylor@linode.com>)
|
4
3
|
#
|
@@ -16,9 +15,12 @@
|
|
16
15
|
# See the License for the specific language governing permissions and
|
17
16
|
# limitations under the License.
|
18
17
|
|
19
|
-
require
|
20
|
-
require
|
21
|
-
|
18
|
+
require "securerandom" unless defined?(SecureRandom)
|
19
|
+
require "kitchen"
|
20
|
+
require "fog/linode"
|
21
|
+
require "fog/json"
|
22
|
+
require "retryable" unless defined?(Retryable)
|
23
|
+
require_relative "linode_version"
|
22
24
|
|
23
25
|
module Kitchen
|
24
26
|
|
@@ -29,235 +31,276 @@ module Kitchen
|
|
29
31
|
class Linode < Kitchen::Driver::Base
|
30
32
|
kitchen_driver_api_version 2
|
31
33
|
plugin_version Kitchen::Driver::LINODE_VERSION
|
32
|
-
|
33
|
-
default_config :
|
34
|
-
|
35
|
-
|
36
|
-
default_config :
|
37
|
-
|
38
|
-
|
39
|
-
default_config :
|
40
|
-
default_config :
|
41
|
-
|
42
|
-
default_config :
|
43
|
-
default_config :
|
44
|
-
|
34
|
+
|
35
|
+
default_config :linode_token do
|
36
|
+
ENV["LINODE_TOKEN"]
|
37
|
+
end
|
38
|
+
default_config :password do
|
39
|
+
ENV["LINODE_PASSWORD"] || SecureRandom.uuid
|
40
|
+
end
|
41
|
+
default_config :label, nil
|
42
|
+
default_config :tags, ["kitchen"]
|
43
|
+
default_config :hostname, nil
|
44
|
+
default_config :image, nil
|
45
|
+
default_config :region do
|
46
|
+
ENV["LINODE_REGION"] || "us-east"
|
47
|
+
end
|
48
|
+
default_config :type, "g6-nanode-1"
|
49
|
+
default_config :stackscript_id, nil
|
50
|
+
default_config :stackscript_data, nil
|
51
|
+
default_config :swap_size, nil
|
52
|
+
default_config :private_ip, false
|
53
|
+
default_config :authorized_users do
|
54
|
+
ENV["LINODE_AUTH_USERS"].to_s.split(",")
|
55
|
+
end
|
45
56
|
default_config :private_key_path do
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
57
|
+
ENV["LINODE_PRIVATE_KEY"] || [
|
58
|
+
File.expand_path("~/.ssh/id_rsa"),
|
59
|
+
File.expand_path("~/.ssh/id_dsa"),
|
60
|
+
File.expand_path("~/.ssh/identity"),
|
61
|
+
File.expand_path("~/.ssh/id_ecdsa"),
|
62
|
+
].find { |path| File.exist?(path) }
|
50
63
|
end
|
64
|
+
expand_path_for :private_key_path
|
51
65
|
default_config :public_key_path do |driver|
|
52
|
-
driver[:private_key_path]
|
66
|
+
if driver[:private_key_path] && File.exist?(driver[:private_key_path] + ".pub")
|
67
|
+
driver[:private_key_path] + ".pub"
|
68
|
+
end
|
53
69
|
end
|
54
|
-
|
55
|
-
default_config :
|
56
|
-
|
57
|
-
required_config :api_key
|
58
|
-
required_config :private_key_path
|
59
|
-
required_config :public_key_path
|
70
|
+
expand_path_for :public_key_path
|
71
|
+
default_config :disable_ssh_password, true
|
72
|
+
default_config :api_retries, 5
|
60
73
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
74
|
+
required_config :linode_token
|
75
|
+
|
76
|
+
def initialize(config)
|
77
|
+
super
|
78
|
+
# configure to retry on timeouts and rate limits by default
|
79
|
+
Retryable.configure do |retry_config|
|
80
|
+
retry_config.log_method = method(:retry_log_method)
|
81
|
+
retry_config.exception_cb = method(:retry_exception_callback)
|
82
|
+
retry_config.on = [Excon::Error::Timeout,
|
83
|
+
Excon::Error::RequestTimeout,
|
84
|
+
Excon::Error::TooManyRequests]
|
85
|
+
retry_config.tries = config[:api_retries]
|
86
|
+
retry_config.sleep = lambda { |n| 2**n } # sleep 1, 2, 4, etc. each try
|
69
87
|
end
|
70
|
-
|
71
|
-
|
72
|
-
|
88
|
+
end
|
89
|
+
|
90
|
+
# create and boot server
|
91
|
+
def create(state)
|
92
|
+
return if state[:linode_id]
|
93
|
+
|
94
|
+
config_hostname
|
95
|
+
config_label
|
73
96
|
server = create_server
|
74
|
-
|
75
|
-
|
76
|
-
state[:linode_id]
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
setup_ssh(state) if bourne_shell?
|
97
|
+
|
98
|
+
update_state(state, server)
|
99
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> created."
|
100
|
+
info "Waiting for linode to boot..."
|
101
|
+
server.wait_for { server.status == "running" }
|
102
|
+
instance.transport.connection(state).wait_until_ready
|
103
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> ready."
|
104
|
+
setup_server(state) if bourne_shell?
|
83
105
|
rescue Fog::Errors::Error, Excon::Errors::Error => ex
|
106
|
+
error "Failed to create server: #{ex.class} - #{ex.message}"
|
84
107
|
raise ActionFailed, ex.message
|
85
108
|
end
|
86
109
|
|
87
110
|
def destroy(state)
|
88
111
|
return if state[:linode_id].nil?
|
89
|
-
server = compute.servers.get(state[:linode_id])
|
90
112
|
|
91
|
-
|
92
|
-
|
93
|
-
|
113
|
+
begin
|
114
|
+
Retryable.retryable do
|
115
|
+
server = compute.servers.get(state[:linode_id])
|
116
|
+
server.destroy
|
117
|
+
end
|
118
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> destroyed."
|
119
|
+
rescue Excon::Error::NotFound
|
120
|
+
info "Linode <#{state[:linode_id]}, #{state[:linode_label]}> not found."
|
121
|
+
end
|
94
122
|
state.delete(:linode_id)
|
95
|
-
state.delete(:
|
123
|
+
state.delete(:linode_label)
|
124
|
+
state.delete(:hostname)
|
125
|
+
state.delete(:ssh_key)
|
126
|
+
state.delete(:password)
|
96
127
|
end
|
97
|
-
|
128
|
+
|
98
129
|
private
|
99
|
-
|
130
|
+
|
100
131
|
def compute
|
101
|
-
Fog::Compute.new(:
|
132
|
+
Fog::Compute.new(provider: :linode, linode_token: config[:linode_token])
|
102
133
|
end
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
else
|
108
|
-
data_center = compute.data_centers.find { |dc| dc.location =~ /#{config[:data_center]}/ }
|
109
|
-
end
|
110
|
-
|
111
|
-
if data_center.nil?
|
112
|
-
fail(UserError, "No match for data_center: #{config[:data_center]}")
|
113
|
-
end
|
114
|
-
info "Got data center: #{data_center.location}..."
|
115
|
-
return data_center
|
134
|
+
|
135
|
+
# generate possible label suffixes
|
136
|
+
def suffixes
|
137
|
+
(0..999).to_a.sample(1000)
|
116
138
|
end
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
139
|
+
|
140
|
+
# generate a unique label
|
141
|
+
def generate_unique_label
|
142
|
+
# Try to generate a unique suffix and make sure nothing else on the account
|
143
|
+
# has the same label.
|
144
|
+
# The iterator is a randomized list from 0 to 999.
|
145
|
+
servers = compute.servers.all
|
146
|
+
suffixes.each do |suffix|
|
147
|
+
label = "#{config[:label]}_#{"%03d" % suffix}"
|
148
|
+
Retryable.retryable do
|
149
|
+
return label if servers.find { |server| server.label == label }.nil?
|
124
150
|
end
|
125
|
-
else
|
126
|
-
flavor = compute.flavors.find { |f| f.name =~ /#{config[:flavor]}/ }
|
127
|
-
end
|
128
|
-
|
129
|
-
if flavor.nil?
|
130
|
-
fail(UserError, "No match for flavor: #{config[:flavor]}")
|
131
151
|
end
|
132
|
-
|
133
|
-
|
152
|
+
# If we're here that means we couldn't make a unique label with the
|
153
|
+
# given prefix. Inform the user that they need to clean up their
|
154
|
+
# account.
|
155
|
+
error "Unable to generate a unique label with prefix #{config[:label]}."
|
156
|
+
error "Might need to cleanup your account."
|
157
|
+
raise(UserError, "Unable to generate a unique label.")
|
134
158
|
end
|
135
|
-
|
136
|
-
def
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
159
|
+
|
160
|
+
def create_server
|
161
|
+
# submit new linode request
|
162
|
+
Retryable.retryable(
|
163
|
+
on: [Excon::Error::BadRequest],
|
164
|
+
tries: config[:api_retries],
|
165
|
+
exception_cb: method(:create_server_exception_callback),
|
166
|
+
log_method: proc {}
|
167
|
+
) do
|
168
|
+
# This will retry if we get a response that the label must be
|
169
|
+
# unique. We wrap both of these in a retry so we generate a
|
170
|
+
# new label when we try again.
|
171
|
+
label = generate_unique_label
|
172
|
+
image = config[:image] || instance.platform.name
|
173
|
+
info "Creating Linode:"
|
174
|
+
info " label: #{label}"
|
175
|
+
info " region: #{config[:region]}"
|
176
|
+
info " image: #{image}"
|
177
|
+
info " type: #{config[:type]}"
|
178
|
+
info " tags: #{config[:tags]}"
|
179
|
+
info " swap_size: #{config[:swap_size]}" if config[:swap_size]
|
180
|
+
info " private_ip: #{config[:private_ip]}" if config[:private_ip]
|
181
|
+
info " stackscript_id: #{config[:stackscript_id]}" if config[:stackscript_id]
|
182
|
+
Retryable.retryable do
|
183
|
+
compute.servers.create(
|
184
|
+
label: label,
|
185
|
+
region: config[:region],
|
186
|
+
image: image,
|
187
|
+
type: config[:type],
|
188
|
+
tags: config[:tags],
|
189
|
+
stackscript_id: config[:stackscript_id],
|
190
|
+
stackscript_data: config[:stackscript_data],
|
191
|
+
swap_size: config[:swap_size],
|
192
|
+
private_ip: config[:private_ip],
|
193
|
+
root_pass: config[:password],
|
194
|
+
authorized_keys: config[:public_key_path] ? [open(config[:public_key_path]).read.strip] : [],
|
195
|
+
authorized_users: config[:authorized_users]
|
196
|
+
)
|
197
|
+
end
|
141
198
|
end
|
142
|
-
|
143
|
-
|
199
|
+
end
|
200
|
+
|
201
|
+
# post build server setup, including configuring the hostname
|
202
|
+
def setup_server(state)
|
203
|
+
info "Setting hostname..."
|
204
|
+
shortname = "#{config[:hostname].split(".")[0]}"
|
205
|
+
instance.transport.connection(state).execute(
|
206
|
+
"echo '127.0.0.1 #{config[:hostname]} #{shortname} localhost\n" +
|
207
|
+
"::1 #{config[:hostname]} #{shortname} localhost' > /etc/hosts && " +
|
208
|
+
"hostnamectl set-hostname #{config[:hostname]} &> /dev/null || " +
|
209
|
+
"hostname #{config[:hostname]} &> /dev/null"
|
210
|
+
)
|
211
|
+
if config[:private_key_path] && config[:public_key_path] && config[:disable_ssh_password]
|
212
|
+
# Disable password auth and bounce SSH
|
213
|
+
info "Disabling SSH password login..."
|
214
|
+
instance.transport.connection(state).execute(
|
215
|
+
"sed -ri 's/^#?PasswordAuthentication .*$/PasswordAuthentication no/' /etc/ssh/sshd_config &&" +
|
216
|
+
"systemctl restart ssh &> /dev/null || " + # Ubuntu, Debian, most systemd distros
|
217
|
+
"systemctl restart sshd &> /dev/null || " + # CentOS 7+
|
218
|
+
"/etc/init.d/sshd restart &> /dev/null || " + # OpenRC (Gentoo, Alpine) and sysvinit
|
219
|
+
"/etc/init.d/ssh restart &> /dev/null || " + # Other OpenRC and sysvinit distros
|
220
|
+
"/etc/rc.d/rc.sshd restart &> /dev/null && " + # Slackware
|
221
|
+
"sleep 1" # Sleep because Slackware's rc script doesn't start SSH back up without it
|
222
|
+
)
|
144
223
|
end
|
145
|
-
info "
|
146
|
-
return image
|
224
|
+
info "Done setting up server."
|
147
225
|
end
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
226
|
+
|
227
|
+
# generate a label prefix if none is supplied and ensure it's less than
|
228
|
+
# the character limit.
|
229
|
+
def config_label
|
230
|
+
unless config[:label]
|
231
|
+
basename = config[:kitchen_root] ? File.basename(config[:kitchen_root]) : "job"
|
232
|
+
jobname = ENV["JOB_NAME"] || ENV["GITHUB_JOB"] || basename
|
233
|
+
config[:label] = "kitchen-#{jobname}-#{instance.name}"
|
154
234
|
end
|
155
|
-
|
156
|
-
|
235
|
+
config[:label] = config[:label].tr(" /", "_")
|
236
|
+
# cut to fit Linode 64 character maximum
|
237
|
+
# we trim to 60 so we can add '_' with a random 3 digit suffix later
|
238
|
+
if config[:label].size >= 60
|
239
|
+
config[:label] = "#{config[:label][0..59]}"
|
157
240
|
end
|
158
|
-
info "Got kernel: #{kernel.name}..."
|
159
|
-
return kernel
|
160
|
-
end
|
161
|
-
|
162
|
-
def create_server
|
163
|
-
data_center = get_dc
|
164
|
-
flavor = get_flavor
|
165
|
-
image = get_image
|
166
|
-
kernel = get_kernel
|
167
|
-
|
168
|
-
# submit new linode request
|
169
|
-
compute.servers.create(
|
170
|
-
:data_center => data_center,
|
171
|
-
:flavor => flavor,
|
172
|
-
:payment_terms => config[:payment_terms],
|
173
|
-
:name => config[:server_name],
|
174
|
-
:image => image,
|
175
|
-
:kernel => kernel,
|
176
|
-
:username => config[:username],
|
177
|
-
:password => config[:password]
|
178
|
-
)
|
179
|
-
end
|
180
|
-
|
181
|
-
def setup_ssh(state)
|
182
|
-
set_ssh_keys
|
183
|
-
state[:ssh_key] = config[:private_key_path]
|
184
|
-
do_ssh_setup(state, config)
|
185
241
|
end
|
186
242
|
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
:password => config[:password],
|
193
|
-
:timeout => config[:ssh_timeout])
|
194
|
-
pub_key = open(config[:public_key_path]).read
|
195
|
-
shortname = "#{config[:vm_hostname].split('.')[0]}"
|
196
|
-
hostsfile = "127.0.0.1 #{config[:vm_hostname]} #{shortname} localhost\n::1 #{config[:vm_hostname]} #{shortname} localhost"
|
197
|
-
@max_interval = 60
|
198
|
-
@max_retries = 10
|
199
|
-
@retries = 0
|
200
|
-
begin
|
201
|
-
ssh.run([
|
202
|
-
%(echo "#{hostsfile}" > /etc/hosts),
|
203
|
-
%(hostnamectl set-hostname #{config[:vm_hostname]}),
|
204
|
-
%(mkdir .ssh),
|
205
|
-
%(echo "#{pub_key}" >> ~/.ssh/authorized_keys),
|
206
|
-
%(passwd -l #{config[:username]})
|
207
|
-
])
|
208
|
-
rescue
|
209
|
-
@retries ||= 0
|
210
|
-
if @retries < @max_retries
|
211
|
-
info "Retrying connection..."
|
212
|
-
sleep [2**(@retries - 1), @max_interval].min
|
213
|
-
@retries += 1
|
214
|
-
retry
|
243
|
+
# configure the hostname either by the provided label or the instance name
|
244
|
+
def config_hostname
|
245
|
+
if config[:hostname].nil?
|
246
|
+
if config[:label]
|
247
|
+
config[:hostname] = "#{config[:label]}"
|
215
248
|
else
|
216
|
-
|
249
|
+
config[:hostname] = "#{instance.name}"
|
217
250
|
end
|
218
251
|
end
|
219
|
-
info "Done setting up SSH access."
|
220
252
|
end
|
221
|
-
|
222
|
-
#
|
223
|
-
def
|
224
|
-
|
225
|
-
|
226
|
-
|
253
|
+
|
254
|
+
# update the kitchen state with the returned server
|
255
|
+
def update_state(state, server)
|
256
|
+
state[:linode_id] = server.id
|
257
|
+
state[:linode_label] = server.label
|
258
|
+
state[:hostname] = server.ipv4[0]
|
259
|
+
if config[:private_key_path] && config[:public_key_path]
|
260
|
+
state[:ssh_key] = config[:private_key_path]
|
227
261
|
else
|
228
|
-
|
229
|
-
|
230
|
-
# use jenkins job name variable. "kitchen_root" turns into "workspace" which is uninformative.
|
231
|
-
jobname = ENV["JOB_NAME"]
|
232
|
-
elsif ENV["TRAVIS_REPO_SLUG"]
|
233
|
-
jobname = ENV["TRAVIS_REPO_SLUG"]
|
234
|
-
else
|
235
|
-
jobname = File.basename(config[:kitchen_root])
|
236
|
-
end
|
237
|
-
config[:server_name] = "kitchen-#{jobname}-#{instance.name}-#{Time.now.to_i.to_s}".tr(" /", "_")
|
238
|
-
end
|
239
|
-
|
240
|
-
# cut to fit Linode 32 character maximum
|
241
|
-
if config[:server_name].is_a?(String) && config[:server_name].size >= 32
|
242
|
-
config[:server_name] = "#{config[:server_name][0..29]}#{rand(10..99)}"
|
262
|
+
warn "Using SSH password auth, some things may not work."
|
263
|
+
state[:password] = config[:password]
|
243
264
|
end
|
244
265
|
end
|
245
|
-
|
246
|
-
#
|
247
|
-
def
|
248
|
-
if
|
249
|
-
|
266
|
+
|
267
|
+
# retry exception callback to check if we need to wait for a rate limit
|
268
|
+
def retry_exception_callback(exception)
|
269
|
+
if exception.class == Excon::Error::TooManyRequests
|
270
|
+
# add a random value between 2 and 20 to the sleep to splay retries
|
271
|
+
sleep_time = exception.response.headers["Retry-After"].to_i + rand(2..20)
|
272
|
+
warn "Rate limit encountered, sleeping #{sleep_time} seconds for it to expire."
|
273
|
+
sleep(sleep_time)
|
250
274
|
end
|
251
275
|
end
|
252
|
-
|
253
|
-
#
|
254
|
-
def
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
276
|
+
|
277
|
+
# retry logging callback to print a message when we're retrying a request
|
278
|
+
def retry_log_method(retries, exception)
|
279
|
+
warn "[Attempt ##{retries}] Retrying because [#{exception.class}]"
|
280
|
+
end
|
281
|
+
|
282
|
+
# create_server callback to check if we can retry the request
|
283
|
+
def create_server_exception_callback(exception)
|
284
|
+
unless exception.response.body.include? "Label must be unique"
|
285
|
+
# not a retriable error.
|
286
|
+
# decode our error(s) and print for the user, then raise a UserError.
|
287
|
+
begin
|
288
|
+
resp_errors = Fog::JSON.decode(exception.response.body)["errors"]
|
289
|
+
resp_errors.each do |resp_error|
|
290
|
+
error "error:"
|
291
|
+
resp_error.each do |key, value|
|
292
|
+
error " #{key}: #{value}"
|
293
|
+
end
|
294
|
+
end
|
295
|
+
rescue
|
296
|
+
# something went wrong with decoding and pretty-printing the error
|
297
|
+
# just raise the original exception.
|
298
|
+
raise exception
|
299
|
+
end
|
300
|
+
raise(UserError, "Bad request when creating server.")
|
260
301
|
end
|
302
|
+
info "Got [#{exception.class}] due to non-unique label when creating server."
|
303
|
+
info "Will try again with a new label if we can."
|
261
304
|
end
|
262
305
|
end
|
263
306
|
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
2
1
|
#
|
3
2
|
# Author:: Brett Taylor (<btaylor@linode.com>)
|
4
3
|
#
|
@@ -20,6 +19,6 @@ module Kitchen
|
|
20
19
|
|
21
20
|
module Driver
|
22
21
|
# Version string for Linode Kitchen driver
|
23
|
-
LINODE_VERSION = "0.
|
22
|
+
LINODE_VERSION = "0.15.0".freeze
|
24
23
|
end
|
25
24
|
end
|