kitchen-linode 0.14.0 → 0.15.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 'kitchen'
20
- require 'fog'
21
- require_relative 'linode_version'
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 :username, 'root'
34
- default_config :password, nil
35
- default_config :server_name, nil
36
- default_config :image, 140
37
- default_config :data_center, 4
38
- default_config :flavor, 1
39
- default_config :payment_terms, 1
40
- default_config :kernel, 138
41
-
42
- default_config :sudo, true
43
- default_config :ssh_timeout, 600
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
- %w(id_rsa).map do |k|
47
- f = File.expand_path("~/.ssh/#{k}")
48
- f if File.exist?(f)
49
- end.compact.first
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] + '.pub' if 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 :api_key, ENV['LINODE_API_KEY']
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
- def create(state)
62
- # create and boot server
63
- config_server_name
64
- set_password
65
-
66
- if state[:linode_id]
67
- info "#{config[:server_name]} (#{state[:linode_id]}) already exists."
68
- return
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
- info("Creating Linode - #{config[:server_name]}")
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
- # assign the machine id for reference in other commands
76
- state[:linode_id] = server.id
77
- state[:hostname] = server.public_ip_address
78
- info("Linode <#{state[:linode_id]}> created.")
79
- info("Waiting for linode to boot...")
80
- server.wait_for { ready? }
81
- info("Linode <#{state[:linode_id]}, #{state[:hostname]}> ready.")
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
- server.destroy
92
-
93
- info("Linode <#{state[:linode_id]}> destroyed.")
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(:pub_ip)
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(:provider => 'Linode', :linode_api_key => config[:api_key])
132
+ Fog::Compute.new(provider: :linode, linode_token: config[:linode_token])
102
133
  end
103
-
104
- def get_dc
105
- if config[:data_center].is_a? Integer
106
- data_center = compute.data_centers.find { |dc| dc.id == config[:data_center] }
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
- def get_flavor
119
- if config[:flavor].is_a? Integer
120
- if config[:flavor] < 1024
121
- flavor = compute.flavors.find { |f| f.id == config[:flavor] }
122
- else
123
- flavor = compute.flavors.find { |f| f.ram == config[:flavor] }
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
- info "Got flavor: #{flavor.name}..."
133
- return flavor
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 get_image
137
- if config[:image].is_a? Integer
138
- image = compute.images.find { |i| i.id == config[:image] }
139
- else
140
- image = compute.images.find { |i| i.name =~ /#{config[:image]}/ }
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
- if image.nil?
143
- fail(UserError, "No match for image: #{config[:image]}")
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 "Got image: #{image.name}..."
146
- return image
224
+ info "Done setting up server."
147
225
  end
148
-
149
- def get_kernel
150
- if config[:kernel].is_a? Integer
151
- kernel = compute.kernels.find { |k| k.id == config[:kernel] }
152
- else
153
- kernel = compute.kernels.find { |k| k.name =~ /#{config[:kernel]}/ }
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
- if kernel.nil?
156
- fail(UserError, "No match for kernel: #{config[:kernel]}")
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
- def do_ssh_setup(state, config)
188
- info "Setting up SSH access for key <#{config[:public_key_path]}>"
189
- info "Connecting <#{config[:username]}@#{state[:hostname]}>..."
190
- ssh = Fog::SSH.new(state[:hostname],
191
- config[:username],
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
- raise
249
+ config[:hostname] = "#{instance.name}"
217
250
  end
218
251
  end
219
- info "Done setting up SSH access."
220
252
  end
221
-
222
- # Set the proper server name in the config
223
- def config_server_name
224
- if config[:server_name]
225
- config[:vm_hostname] = "#{config[:server_name]}"
226
- config[:server_name] = "kitchen-#{config[:server_name]}-#{instance.name}-#{Time.now.to_i.to_s}"
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
- config[:vm_hostname] = "#{instance.name}"
229
- if ENV["JOB_NAME"]
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
- # ensure a password is set
247
- def set_password
248
- if config[:password].nil?
249
- config[:password] = [*('a'..'z'),*('A'..'Z'),*('0'..'9')].sample(15).join
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
- # set ssh keys
254
- def set_ssh_keys
255
- if config[:private_key_path]
256
- config[:private_key_path] = File.expand_path(config[:private_key_path])
257
- end
258
- if config[:public_key_path]
259
- config[:public_key_path] = File.expand_path(config[:public_key_path])
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.14.0"
22
+ LINODE_VERSION = "0.15.0".freeze
24
23
  end
25
24
  end