cem_acpt 0.2.6-universal-java-17

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.rspec +3 -0
  4. data/CODEOWNERS +1 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +93 -0
  7. data/README.md +150 -0
  8. data/Rakefile +6 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/cem_acpt.gemspec +39 -0
  12. data/exe/cem_acpt +84 -0
  13. data/lib/cem_acpt/bootstrap/bootstrapper.rb +206 -0
  14. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +129 -0
  15. data/lib/cem_acpt/bootstrap/operating_system.rb +17 -0
  16. data/lib/cem_acpt/bootstrap.rb +12 -0
  17. data/lib/cem_acpt/context.rb +153 -0
  18. data/lib/cem_acpt/core_extensions.rb +108 -0
  19. data/lib/cem_acpt/image_name_builder.rb +104 -0
  20. data/lib/cem_acpt/logging.rb +351 -0
  21. data/lib/cem_acpt/platform/base/cmd.rb +71 -0
  22. data/lib/cem_acpt/platform/base.rb +78 -0
  23. data/lib/cem_acpt/platform/gcp/cmd.rb +345 -0
  24. data/lib/cem_acpt/platform/gcp/compute.rb +332 -0
  25. data/lib/cem_acpt/platform/gcp.rb +85 -0
  26. data/lib/cem_acpt/platform/vmpooler.rb +24 -0
  27. data/lib/cem_acpt/platform.rb +103 -0
  28. data/lib/cem_acpt/puppet_helpers.rb +39 -0
  29. data/lib/cem_acpt/rspec_utils.rb +242 -0
  30. data/lib/cem_acpt/shared_objects.rb +537 -0
  31. data/lib/cem_acpt/spec_helper_acceptance.rb +184 -0
  32. data/lib/cem_acpt/test_data.rb +146 -0
  33. data/lib/cem_acpt/test_runner/run_handler.rb +187 -0
  34. data/lib/cem_acpt/test_runner/runner.rb +210 -0
  35. data/lib/cem_acpt/test_runner/runner_result.rb +103 -0
  36. data/lib/cem_acpt/test_runner.rb +10 -0
  37. data/lib/cem_acpt/utils.rb +144 -0
  38. data/lib/cem_acpt/version.rb +5 -0
  39. data/lib/cem_acpt.rb +34 -0
  40. data/sample_config.yaml +58 -0
  41. metadata +218 -0
@@ -0,0 +1,345 @@
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
+ local_exec('compute os-login describe-profile', project_flag: false)['posixAccounts'][0]['username']
341
+ rescue StandardError => e
342
+ raise "failed to find authenticated user name from os-login profile: #{e.message}"
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,332 @@
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