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.
@@ -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