cem_acpt 0.2.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +30 -0
  3. data/Gemfile +4 -3
  4. data/Gemfile.lock +95 -43
  5. data/README.md +144 -83
  6. data/cem_acpt.gemspec +12 -7
  7. data/exe/cem_acpt +41 -7
  8. data/lib/cem_acpt/config.rb +340 -0
  9. data/lib/cem_acpt/core_extensions.rb +17 -61
  10. data/lib/cem_acpt/goss/api/action_response.rb +175 -0
  11. data/lib/cem_acpt/goss/api.rb +83 -0
  12. data/lib/cem_acpt/goss.rb +8 -0
  13. data/lib/cem_acpt/image_name_builder.rb +0 -9
  14. data/lib/cem_acpt/logging/formatter.rb +97 -0
  15. data/lib/cem_acpt/logging.rb +168 -142
  16. data/lib/cem_acpt/platform/base.rb +26 -37
  17. data/lib/cem_acpt/platform/gcp.rb +48 -62
  18. data/lib/cem_acpt/platform.rb +30 -28
  19. data/lib/cem_acpt/provision/terraform/linux.rb +47 -0
  20. data/lib/cem_acpt/provision/terraform/os_data.rb +72 -0
  21. data/lib/cem_acpt/provision/terraform/windows.rb +22 -0
  22. data/lib/cem_acpt/provision/terraform.rb +193 -0
  23. data/lib/cem_acpt/provision.rb +20 -0
  24. data/lib/cem_acpt/puppet_helpers.rb +0 -1
  25. data/lib/cem_acpt/test_data.rb +23 -13
  26. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +104 -0
  27. data/lib/cem_acpt/test_runner/log_formatter.rb +10 -0
  28. data/lib/cem_acpt/test_runner.rb +170 -3
  29. data/lib/cem_acpt/utils/puppet.rb +29 -0
  30. data/lib/cem_acpt/utils/ssh.rb +197 -0
  31. data/lib/cem_acpt/utils/terminal.rb +27 -0
  32. data/lib/cem_acpt/utils.rb +4 -138
  33. data/lib/cem_acpt/version.rb +1 -1
  34. data/lib/cem_acpt.rb +73 -23
  35. data/lib/terraform/gcp/linux/goss/puppet_idempotent.yaml +10 -0
  36. data/lib/terraform/gcp/linux/goss/puppet_noop.yaml +12 -0
  37. data/lib/terraform/gcp/linux/main.tf +191 -0
  38. data/lib/terraform/gcp/linux/systemd/goss-acpt.service +8 -0
  39. data/lib/terraform/gcp/linux/systemd/goss-idempotent.service +8 -0
  40. data/lib/terraform/gcp/linux/systemd/goss-noop.service +8 -0
  41. data/lib/terraform/gcp/windows/.keep +0 -0
  42. data/sample_config.yaml +22 -21
  43. metadata +151 -51
  44. data/lib/cem_acpt/bootstrap/bootstrapper.rb +0 -206
  45. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +0 -129
  46. data/lib/cem_acpt/bootstrap/operating_system.rb +0 -17
  47. data/lib/cem_acpt/bootstrap.rb +0 -12
  48. data/lib/cem_acpt/context.rb +0 -153
  49. data/lib/cem_acpt/platform/base/cmd.rb +0 -71
  50. data/lib/cem_acpt/platform/gcp/cmd.rb +0 -353
  51. data/lib/cem_acpt/platform/gcp/compute.rb +0 -332
  52. data/lib/cem_acpt/platform/vmpooler.rb +0 -24
  53. data/lib/cem_acpt/rspec_utils.rb +0 -242
  54. data/lib/cem_acpt/shared_objects.rb +0 -537
  55. data/lib/cem_acpt/spec_helper_acceptance.rb +0 -184
  56. data/lib/cem_acpt/test_runner/run_handler.rb +0 -187
  57. data/lib/cem_acpt/test_runner/runner.rb +0 -210
  58. data/lib/cem_acpt/test_runner/runner_result.rb +0 -103
@@ -1,353 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CemAcpt::Platform::Gcp
4
- require 'json'
5
- require 'open3'
6
- require_relative File.join(__dir__, '..', 'base', 'cmd.rb')
7
-
8
- # This class provides methods to run gcloud commands. It allows for default values to be
9
- # set for the project, zone, and user and can also find these values from the local config.
10
- # Additionally, this class provides a way to run SSH commands against GCP VMs using IAP.
11
- class Cmd < CemAcpt::Platform::CmdBase
12
- def initialize(project: nil, zone: nil, out_format: nil, filter: nil, user_name: nil, local_port: nil, ssh_key: nil)
13
- super(env: { 'CLOUDSDK_PYTHON_SITEPACKAGES' => '1' })
14
- require 'net/ssh'
15
- require 'net/ssh/proxy/command'
16
-
17
- @project = project unless project.nil?
18
- @zone = zone unless zone.nil?
19
- @default_out_format = out_format
20
- @default_filter = filter
21
- @user_name = user_name
22
- @local_port = local_port
23
- @ssh_key = ssh_key
24
- raise CemAcpt::Platform::CmdError, 'gcloud command not available' unless gcloud?
25
- raise CemAcpt::Platform::CmdError, 'gcloud is not authenticated' unless authenticated?
26
- end
27
-
28
- # Returns either the project name passed in during object initialization
29
- # or the project name from the local config.
30
- def project
31
- @project ||= project_from_config&.chomp
32
- end
33
-
34
- # Returns either the zone name passed in during object initialization
35
- # or the zone name from the local config.
36
- def zone
37
- @zone ||= zone_from_config&.chomp
38
- end
39
-
40
- # Returns either the user name passed in during object initialization
41
- # or queries the current active, authenticated user from gcloud and returns the name.
42
- def user_name
43
- @user_name ||= authenticated_user_name
44
- end
45
-
46
- # Returns the format string passed in during object initialization or
47
- # the default format string.
48
- def format(out_format = nil)
49
- out_format&.chomp || @default_out_format
50
- end
51
-
52
- # Returns the filter string passed in during object initialization or
53
- # the default filter string.
54
- def filter(out_filter = nil)
55
- out_filter&.chomp || @default_filter
56
- end
57
-
58
- def local_port
59
- @local_port ||= rand(49_512..65_535)
60
- end
61
-
62
- def ssh_key
63
- return @ssh_key unless @ssh_key.nil?
64
-
65
- if File.exist?(File.join([ENV['HOME'], '.ssh', 'acpt_test_key']))
66
- @ssh_key = File.join([ENV['HOME'], '.ssh', 'acpt_test_key'])
67
- else
68
- logger.debug("Test SSH key not found at #{File.join([ENV['HOME'], '.ssh', 'acpt_test_key'])}, using default")
69
- @ssh_key = File.join([ENV['HOME'], '.ssh', 'google_compute_engine'])
70
- end
71
- @ssh_key
72
- end
73
-
74
- # Returns a formatted hash of ssh options to be used with Net::SSH.start.
75
- # If you pass in a GCP VM instance name, this method will configure the
76
- # IAP tunnel ProxyCommand to use. If you pass in an opts hash, it will
77
- # merge the options with the default options.
78
- def ssh_opts(instance_name: nil, use_proxy_command: true, opts: {})
79
- base_opts = default_ssh_opts
80
- if use_proxy_command
81
- base_opts[:proxy] = proxy_command(instance_name, port: base_opts[:port])
82
- base_opts[:host_key_alias] = vm_alias(instance_name)
83
- end
84
- base_opts.merge(opts).reject { |_, v| v.nil? }
85
- end
86
-
87
- # Runs `gcloud` commands locally.
88
- def local_exec(command, out_format: 'json', out_filter: nil, project_flag: true)
89
- final_command = format_gcloud_command(command, out_format: out_format, out_filter: out_filter, project_flag: project_flag)
90
- stdout, stderr, status = Open3.capture3(env, final_command)
91
- raise "gcloud command '#{final_command}' failed: #{stderr}" unless status.success?
92
-
93
- if format(out_format) == 'json'
94
- begin
95
- ::JSON.parse(stdout)
96
- rescue ::JSON::ParserError
97
- stdout.chomp
98
- end
99
- else
100
- stdout.chomp
101
- end
102
- end
103
-
104
- # Stops a GCP VM instance.
105
- def stop_instance(instance_name)
106
- local_exec("compute instances stop #{instance_name}")
107
- end
108
-
109
- # Deletes a GCP VM instance.
110
- def delete_instance(instance_name)
111
- local_exec("compute instances delete #{instance_name} --quiet")
112
- rescue StandardError
113
- # Ignore errors when deleting instances.
114
- end
115
-
116
- # Returns a Hash describing a GCP VM instance.
117
- def vm_describe(instance_name, out_format: 'json', out_filter: nil)
118
- local_exec("compute instances describe #{instance_name}", out_format: out_format, out_filter: out_filter)
119
- end
120
-
121
- # This function runs the specified command as the currently authenticated user
122
- # on the given CGP VM via SSH. Using `ssh` does not invoke the gcloud command
123
- # and is dependent on the system's SSH configuration.
124
- def ssh(instance_name, cmd, ignore_command_errors: false, use_proxy_command: true, opts: {})
125
- Net::SSH.start(instance_name, user_name,
126
- ssh_opts(instance_name: instance_name, use_proxy_command: use_proxy_command, opts: opts)) do |ssh|
127
- logger.debug("Running SSH command '#{cmd}' on instance '#{instance_name}'")
128
- result = ssh.exec!(cmd)
129
- if result.exitstatus != 0 && !ignore_command_errors
130
- abridged_cmd = cmd.length > 100 ? "#{cmd[0..100]}..." : cmd
131
- raise "Failed to run SSH command \"#{abridged_cmd}\" on #{instance_name}: #{result}"
132
- end
133
- result
134
- end
135
- end
136
-
137
- # Uploads a file to the specified VM.
138
- def scp_upload(instance_name, local, remote, use_proxy_command: true, scp_opts: {}, opts: {})
139
- raise "Local file #{local} does not exist" unless File.exist?(local)
140
-
141
- cmd = [
142
- 'compute',
143
- 'scp',
144
- ]
145
- cmd << '--strict-host-key-checking=no'
146
- cmd << "--tunnel-through-iap" if use_proxy_command
147
- cmd << "--recurse" if scp_opts[:recurse]
148
- cmd << "#{local} #{instance_name}:#{remote}"
149
- local_exec(cmd.join(' '), out_format: 'json')
150
- end
151
-
152
- # Downloads a file from the specified VM.
153
- def scp_download(instance_name, remote, local, use_proxy_command: true, scp_opts: {}, opts: {})
154
- raise "Local file #{local} does not exist" unless File.exist?(local)
155
-
156
- cmd = [
157
- 'compute',
158
- 'scp',
159
- ]
160
- cmd << '--strict-host-key-checking=no'
161
- cmd << '--tunnel-through-iap' if use_proxy_command
162
- cmd << '--recurse' if scp_opts[:recurse]
163
- cmd << "#{instance_name}:#{remote} #{local}"
164
- local_exec(cmd.join(' '), out_format: 'json')
165
- end
166
-
167
- def ssh_ready?(instance_name, opts: {})
168
- ssh_options = ssh_opts(instance_name: instance_name, opts: opts)
169
- logger.debug("Testing SSH connection to #{instance_name} with options #{ssh_options}")
170
- gcloud_ssh(instance_name, 'echo "SSH is ready"')
171
- logger.debug('Removing ecdsa & ed25519 host keys from config due to bug in jruby-openssl')
172
- gcloud_ssh(instance_name, 'sudo sed -E -i "s;HostKey /etc/ssh/ssh_host_(ecdsa|ed25519)_key;;g" /etc/ssh/sshd_config')
173
- logger.debug('Restarting SSH service')
174
- gcloud_ssh(instance_name, 'sudo systemctl restart sshd', ignore_command_errors: true)
175
- logger.info("SSH connection to #{instance_name} is ready")
176
- true
177
- rescue StandardError => e
178
- logger.debug("SSH connection to #{instance_name} failed: #{e}")
179
- false
180
- end
181
-
182
- # This function spawns a background thread to run a GCP IAP tunnel, run the given
183
- # code block, then kill the thread. The code block will be yielded ssh_opts that
184
- # are used to configure SSH connections over the IAP tunnel. The IAP tunnel is
185
- # run in it's own thread and will not block the main thread. The IAP tunnel is
186
- # killed when the code block exits. All SSH connections made in the code block
187
- # must be made to the host '127.0.0.1' using the yielded ssh_opts and not the VM name.
188
- # @param instance_name [String] The name of the GCP VM instance to connect to.
189
- # @param instance_port [Integer] The port to connect to on the GCP VM instance.
190
- # @param disable_connection_check [Boolean] If true, the connection check will be disabled.
191
- def with_iap_tunnel(instance_name, instance_port = 22, disable_connection_check: false, &block)
192
- cmd = [
193
- 'compute start-iap-tunnel',
194
- "#{instance_name} #{instance_port}",
195
- "--local-host-port=localhost:#{local_port}",
196
- ]
197
- cmd << '--disable-connection-check' if disable_connection_check
198
- tunnel_ssh_opts = {
199
- proxy: nil,
200
- port: local_port,
201
- forward_agent: false,
202
- }
203
- final_cmd = format_gcloud_command(cmd.join(' '), out_format: nil, project_flag: false)
204
- tunnel_pid = Process.spawn(env, final_cmd)
205
- begin
206
- block.call(ssh_opts(use_proxy_command: false, opts: tunnel_ssh_opts))
207
- ensure
208
- Process.kill('TERM', tunnel_pid)
209
- end
210
- end
211
-
212
- def project_from_config
213
- local_exec('config get-value project', out_format: nil, project_flag: false)
214
- end
215
-
216
- def zone_from_config
217
- local_exec('config get-value compute/zone', out_format: nil, project_flag: false)
218
- end
219
-
220
- def run_shell(instance_name, cmd, opts = {})
221
- ssh_opts = opts.key?(:ssh_opts) ? opts[:ssh_opts] : {}
222
- command = [
223
- 'sudo -n -u root -i',
224
- cmd,
225
- ]
226
- ssh(
227
- instance_name,
228
- command.join(' '),
229
- ignore_command_errors: true,
230
- opts: ssh_opts,
231
- )
232
- end
233
-
234
- def apply_manifest(instance_name, manifest, opts = {})
235
- unless opts[:apply][:no_upload]
236
- with_temp_manifest_file(manifest) do |tf|
237
- upload_temp_manifest(instance_name, tf.path, remote_path: '/tmp/acpt_manifest.pp', opts: opts)
238
- end
239
- end
240
- apply_cmd = [opts[:puppet_path], 'apply', '/tmp/acpt_manifest.pp']
241
- apply_cmd << '--trace' if opts[:apply][:trace]
242
- apply_cmd << "--hiera_config=#{opts[:apply][:hiera_config]}" if opts[:apply][:hiera_config]
243
- apply_cmd << '--debug' if opts[:apply][:debug]
244
- apply_cmd << '--noop' if opts[:apply][:noop]
245
- apply_cmd << '--detailed-exitcodes' if opts[:apply][:detailed_exitcodes]
246
-
247
- run_shell(
248
- instance_name,
249
- apply_cmd.join(' '),
250
- opts
251
- )
252
- end
253
-
254
- private
255
-
256
- def format_gcloud_command(command, out_format: 'json', out_filter: nil, project_flag: true)
257
- cmd_parts = ['gcloud', command]
258
- cmd_parts << "--project=#{project.chomp}" unless !project_flag || project.nil?
259
- cmd_parts << "--format=#{format(out_format)}" if format(out_format)
260
- cmd_parts << "--filter=\"#{filter(out_filter)}\"" if filter(out_filter)
261
- cmd_parts.join(' ')
262
- end
263
-
264
- def with_temp_manifest_file(manifest, file_name: 'acpt_manifest')
265
- require 'tempfile'
266
- tf = Tempfile.new(file_name)
267
- tf.write(manifest)
268
- tf.close
269
- begin
270
- yield tf
271
- ensure
272
- tf.unlink
273
- end
274
- end
275
-
276
- def upload_temp_manifest(instance_name, local_path, remote_path: '/tmp/acpt_manifest.pp', opts: {})
277
- scp_upload(instance_name, local_path, remote_path, opts: opts[:ssh_opts]) unless opts[:apply][:no_upload]
278
- end
279
-
280
- def gcloud_ssh(instance_name, command, ignore_command_errors: false)
281
- local_exec("compute ssh --ssh-key-file #{ssh_key} --tunnel-through-iap #{instance_name} --command='#{command}'")
282
- rescue StandardError => e
283
- raise e unless ignore_command_errors
284
- end
285
-
286
- def vm_alias(vm)
287
- "compute.#{vm_describe(vm)['id']}"
288
- end
289
-
290
- # Default options for Net::SSH
291
- def default_ssh_opts
292
- {
293
- auth_methods: ['publickey'],
294
- check_host_ip: false,
295
- compression: true,
296
- config: false,
297
- keys: [ssh_key],
298
- keys_only: true,
299
- kex: ['diffie-hellman-group-exchange-sha256'], # ecdh algos cause jruby to shit the bed
300
- non_interactive: true,
301
- port: 22,
302
- user: user_name,
303
- user_known_hosts_file: File.join(ENV['HOME'], '.ssh', 'acpt_test_known_hosts'),
304
- verify_host_key: :never,
305
- }
306
- end
307
-
308
- # Returns a Proxy::Command object to use for the SSH option ProxyCommand.
309
- # This works in the same way that `gcloud compute ssh --tunnel-through-iap` does.
310
- def proxy_command(instance_name, port: 22, quiet: true, verbosity: 'debug')
311
- proxy_command = [
312
- 'gcloud compute start-iap-tunnel',
313
- "#{instance_name} #{port}",
314
- '--listen-on-stdin',
315
- "--project=#{project}",
316
- "--zone=#{zone}",
317
- ]
318
- #proxy_command << '--no-user-output-enabled --quiet' if quiet
319
- proxy_command << "--verbosity=#{verbosity}" unless quiet || verbosity.nil? || verbosity.empty?
320
- Net::SSH::Proxy::Command.new(proxy_command.join(' '))
321
- end
322
-
323
- # This function checks to ensure that the gcloud tool is available on the system.
324
- def gcloud?
325
- ver = local_exec('--version', out_format: nil, project_flag: false)
326
- !ver.nil? && !ver.empty?
327
- rescue RuntimeError, Errno::ENOENT
328
- false
329
- end
330
-
331
- # This function checks to ensure that a user is authenticated with gcloud.
332
- def authenticated?
333
- local_exec('auth list', project_flag: false).any? { |a| a['status'] == 'ACTIVE' }
334
- rescue RuntimeError, Errno::ENOENT
335
- false
336
- end
337
-
338
- # This function returns the currently authenticated user's name sanitized for use in SSH
339
- def authenticated_user_name
340
- uname = nil
341
- begin
342
- uname = local_exec('config get-value account', out_format: nil, project_flag: false)
343
- rescue RuntimeError
344
- auth_json = local_exec('auth list')
345
- uname = auth_json.find { |x| x['status'] == 'ACTIVE' }['account']
346
- end
347
-
348
- raise 'failed to find authenticated user name' unless uname
349
-
350
- uname.gsub(/[^a-zA-Z0-9_]/, '_')
351
- end
352
- end
353
- end
@@ -1,332 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CemAcpt::Platform::Gcp
4
- require_relative 'cmd'
5
- require_relative File.join(__dir__, '..', '..', 'logging.rb')
6
-
7
- module Helper
8
- def add_cmd(cmd_obj)
9
- @cmd = cmd_obj
10
- end
11
-
12
- def cmd
13
- @cmd ||= CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json')
14
- end
15
-
16
- def cmd_flag_vars?(*vars)
17
- vars.none? { |v| v.nil? || (v.empty? if v.respond_to?(:empty?)) }
18
- end
19
- end
20
-
21
- # This class represents a GCP project
22
- class Project
23
- include Helper
24
-
25
- def initialize(name: nil, zone: nil)
26
- @name = name
27
- @zone = zone
28
- end
29
-
30
- def name
31
- @name ||= cmd.project_from_config
32
- end
33
-
34
- def zone
35
- @zone ||= cmd.zone_from_config
36
- end
37
-
38
- def cmd_flag
39
- "--project=#{name}"
40
- end
41
- end
42
-
43
- # This class represents a GCP VM service account
44
- class ServiceAccount
45
- include Helper
46
-
47
- attr_reader :name, :scopes
48
-
49
- def initialize(name: nil, scopes: ['devstorage.read_only', 'logging.write', 'monitoring.write', 'servicecontrol', 'service.management.readonly', 'trace.append' ])
50
- @name = name
51
- @scopes = scopes.map { |s| "https://www.googleapis.com/auth/#{s}" }
52
- end
53
-
54
- def cmd_flag
55
- "--service-account=#{name} --scopes=#{scopes.join(',')}" if cmd_flag_vars?(name, scopes)
56
- end
57
- end
58
-
59
- # This class represents a GCP VM disk
60
- class Disk
61
- include Helper
62
-
63
- attr_reader :project, :name, :size, :image_name
64
-
65
- def initialize(project: nil, name: 'disk-1', image_name: '', size: 20, type: 'pd-standard')
66
- @project = project
67
- @name = name
68
- @image_name = image_name
69
- @size = size
70
- @type = type
71
- end
72
-
73
- def cmd_flag
74
- return unless cmd_flag_vars?(name, image, size, type)
75
-
76
- '--create-disk=auto-delete=yes,boot=yes,' \
77
- "device-name=#{name},image=#{image}," \
78
- "mode=rw,size=#{size},type=#{type}"
79
- end
80
-
81
- def type
82
- type_path(@type)
83
- end
84
-
85
- def image
86
- @image ||= image_path(@image_name)
87
- end
88
-
89
- private
90
-
91
- def image_path(image_str)
92
- raise ArgumentError, 'image must be a non-empty string' unless image_str.is_a?(String) && !image_str.empty?
93
-
94
- data = cmd.local_exec('compute images list', out_filter: "name ~ '#{image_str}$'").first
95
- if data.nil? || data.empty?
96
- data = cmd.local_exec('compute images list', out_filter: "family ~ '#{image_str}$'").first
97
- end
98
- raise "Image #{image_str} not found" unless data
99
- raise "Image status invalid for image #{data['selfLink']}: #{data[status]}" unless data['status'] == 'READY'
100
-
101
- data['selfLink'].split('v1/')[-1]
102
- end
103
-
104
- def type_path(type)
105
- raise ArgumentError, 'type must be a non-empty string' unless type.is_a?(String) && !type.empty?
106
-
107
- "projects/#{project.name}/zones/#{project.zone}/diskTypes/#{type}"
108
- end
109
- end
110
-
111
- # This class represents a GCP VM network interface
112
- class NetworkInterface
113
- include Helper
114
-
115
- attr_reader :tier, :subnetwork
116
-
117
- def initialize(tier: 'STANDARD', subnetwork: 'default')
118
- @tier = tier.upcase
119
- @subnetwork = subnetwork
120
- end
121
-
122
- def cmd_flag
123
- "--network-interface=network-tier=#{tier},subnet=#{subnetwork}" if cmd_flag_vars?(tier, subnetwork)
124
- end
125
- end
126
-
127
- # This class represents GCP VM metadata
128
- class Metadata
129
- include Helper
130
-
131
- def initialize(**data)
132
- @data = data.transform_keys(&:to_s)
133
- end
134
-
135
- def data
136
- format_data(@data)
137
- end
138
-
139
- def cmd_flag
140
- return unless cmd_flag_vars?(data)
141
-
142
- data_from_file = data.select { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
143
- normalized_data = data.reject { |k, _v| k == 'ssh-keys' }.map { |k, v| "#{k}=#{v}" }.join(',')
144
- cmd = []
145
- cmd << "--metadata-from-file=#{data_from_file}" unless data_from_file.empty?
146
- cmd << "--metadata=#{normalized_data}" unless normalized_data.empty?
147
- cmd.join(' ')
148
- end
149
-
150
- private
151
-
152
- def format_data(data)
153
- default_data = { 'enable-oslogin' => 'TRUE' }
154
- if data['ephemeral_ssh_key']
155
- args = {
156
- username: (data.dig('ephemeral_ssh_key', 'username') || cmd.user_name),
157
- }
158
- args[:lifetime] = data.dig('ephemeral_ssh_key', 'lifetime') if data.dig('ephemeral_ssh_key', 'lifetime')
159
- args[:keydir] = data.dig('ephemeral_ssh_key', 'keydir') if data.dig('ephemeral_ssh_key', 'keydir')
160
- priv_key_path, ssh_keys = ephemeral_ssh_key(**args)
161
- default_data['priv_key_local_path'] = priv_key_path
162
- default_data['ssh-keys'] = ssh_keys
163
- data.delete('ephemeral_ssh_key')
164
- end
165
- default_data.merge(data)
166
- end
167
-
168
- def ephemeral_ssh_key(username: nil, lifetime: 2, keydir: '/tmp/acpt_ssh_keys')
169
- raise ArgumentError, 'username must be a non-empty string' unless username.is_a?(String) && !username.empty?
170
-
171
- keypath = File.join(keydir, SecureRandom.hex(10))
172
- Dir.mkdir(keydir) unless Dir.exist?(keydir)
173
- lifetime_in_seconds = (lifetime.to_i * 60 * 60)
174
- iso8601_expiration = (Time.now.utc + lifetime_in_seconds).strftime('%Y-%m-%dT%H:%M:%S%z')
175
- keygen_cmd = [
176
- 'ssh-keygen',
177
- '-t ecdsa',
178
- '-b 521',
179
- "-C 'google-ssh {\"userName\":\"#{username}\",\"expireOn\":\"#{iso8601_expiration}\"}'",
180
- "-f #{keypath}",
181
- '-N ""',
182
- '-q',
183
- ].join(' ')
184
- _, stderr, status = Open3.capture3(keygen_cmd)
185
- raise stderr unless status.success?
186
-
187
- File.write("#{keypath}.pub", "#{username}:#{File.read("#{keypath}.pub")}")
188
- [keypath, "#{keypath}.pub"]
189
- rescue StandardError => e
190
- raise "Failed to generate ephemeral SSH key: #{e} #{e.backtrace}"
191
- end
192
- end
193
-
194
- # This class represents a GCP VM. It is composed of various component classes.
195
- class VM
196
- include Helper
197
- include CemAcpt::Logging
198
-
199
- attr_accessor :name, :project, :service_account, :disk, :network_interface, :machine_type, :metadata
200
- attr_reader :cmd
201
-
202
- def initialize(name, components: {})
203
- @name = name
204
- @components = components
205
- @machine_type = components[:machine_type]
206
- @cmd = CemAcpt::Platform::Gcp::Cmd.new(
207
- project: components[:project][:name],
208
- zone: components[:project][:zone],
209
- out_format: 'json',
210
- local_port: components[:local_port],
211
- )
212
- @configured = false
213
- end
214
-
215
- def configure!
216
- return @configured if @configured
217
-
218
- @project = Project.new(**@components[:project])
219
- @project.add_cmd(@cmd)
220
- @service_account = @components.key?(:service_account) ? ServiceAccount.new(**@components[:service_account]) : nil
221
- @disk = Disk.new(project: @project, **@components[:disk])
222
- @disk.add_cmd(@cmd)
223
- @network_interface = NetworkInterface.new(**@components[:network_interface])
224
- @machine_type = @components[:machine_type]
225
- @metadata = Metadata.new(**@components[:metadata])
226
- @metadata.add_cmd(@cmd)
227
- @configured = true
228
- end
229
-
230
- def info
231
- describe_cmd = "compute instances describe #{name}"
232
- logger.debug("Gathering info for VM #{name} with gcloud command: #{describe_cmd}")
233
- data = @cmd.local_exec(describe_cmd, out_format: 'json')
234
- opts = @cmd.ssh_opts(instance_name: name)
235
- {
236
- node_data: data,
237
- transport: :ssh,
238
- ssh_opts: opts,
239
- }
240
- end
241
-
242
- def create
243
- # Add the test ssh key to os-login
244
- logger.debug("Adding test SSH key to os-login for #{name}")
245
- @cmd.local_exec("compute os-login ssh-keys add --key-file #{@cmd.ssh_key}.pub --project #{project.name} --ttl 4h")
246
- @cmd.local_exec(create_cmd)
247
- rescue StandardError => e
248
- raise "Failed to create VM #{name} with command #{create_cmd}: #{e}"
249
- end
250
-
251
- def ready?
252
- logger.debug("Checking if VM #{name} is ready")
253
- instance_status = @cmd.local_exec('compute instances list', out_filter: "NAME = #{name}").first['status']
254
- logger.debug("Instance #{name} status: #{instance_status}")
255
- return false unless instance_status == 'RUNNING'
256
-
257
- logger.debug("Checking instance #{name} SSH connectivity")
258
- @cmd.ssh_ready?(name)
259
- rescue StandardError, Exception
260
- false
261
- end
262
-
263
- def destroy
264
- @cmd.delete_instance(name)
265
- end
266
-
267
- def install_puppet_module_package(module_pkg_path, remote_path, puppet_path)
268
- @cmd.scp_upload(@name, module_pkg_path, remote_path)
269
- logger.info("Uploaded module package #{module_pkg_path} to #{remote_path} on #{@name}")
270
- @cmd.ssh(@name, "sudo #{puppet_path} module install #{remote_path}")
271
- logger.info("Installed module package #{remote_path} on #{@name}")
272
- end
273
-
274
- private
275
-
276
- # Determines whether the given instance name is a fully qualified
277
- # hostname or not.
278
- def hostname?(instance_name)
279
- instance_name.include?('.')
280
- end
281
-
282
- # Validates a GCP instance name.
283
- def validate_instance_name(instance_name)
284
- if instance_name.nil? || !instance_name.is_a?(String) || instance_name.empty?
285
- raise ArgumentError, 'Instance name must be a non-empty string'
286
- end
287
- raise ArgumentError, 'Instance name must be less than 253 characters' if instance_name.length > 253
288
-
289
- return true unless hostname?(instance_name)
290
-
291
- labels = instance_name.split('.')
292
- labels.each do |label|
293
- unless label.match?(%r{^[a-z]([-a-z0-9]*[a-z0-9])?$})
294
- raise ArgumentError, "Instance name portion #{label} contains invalid characters"
295
- end
296
- raise ArgumentError, "Instance name portion #{label} is too long" if label.length > 63
297
- end
298
- true
299
- end
300
-
301
- def create_cmd
302
- validate_instance_name(name)
303
- if hostname?(name)
304
- instance_name = name.split('.')[0]
305
- hostname = name
306
- else
307
- instance_name = name
308
- hostname = nil
309
- end
310
-
311
- cmd = [
312
- 'compute',
313
- 'instances',
314
- 'create',
315
- instance_name,
316
- '--async',
317
- "--machine-type=#{machine_type}",
318
- '--maintenance-policy=MIGRATE',
319
- '--no-shielded-secure-boot',
320
- '--shielded-vtpm',
321
- '--shielded-integrity-monitoring',
322
- '--reservation-affinity=any',
323
- '--tags=acpt-test-node',
324
- ]
325
- [disk&.cmd_flag, network_interface&.cmd_flag, metadata&.cmd_flag, service_account&.cmd_flag].each do |flag|
326
- cmd << flag unless flag.nil?
327
- end
328
- cmd << "--hostname=#{hostname}" unless hostname.nil?
329
- cmd.join(' ')
330
- end
331
- end
332
- end