Class: CemAcpt::Platform::Gcp::Cmd

Inherits:
CmdBase
  • Object
show all
Defined in:
lib/cem_acpt/platform/gcp/cmd.rb

Overview

This class provides methods to run gcloud commands. It allows for default values to be set for the project, zone, and user and can also find these values from the local config. Additionally, this class provides a way to run SSH commands against GCP VMs using IAP.

Constant Summary

Constants included from Logging

Logging::LEVEL_MAP

Instance Attribute Summary

Attributes inherited from CmdBase

#env

Instance Method Summary collapse

Methods inherited from CmdBase

#trim_output, #with_timed_retry

Methods included from Logging

current_log_config, #current_log_config, current_log_format, #current_log_format, current_log_level, #current_log_level, included, logger, #logger, new_log_config, #new_log_config, new_log_formatter, #new_log_formatter, new_log_level, #new_log_level, new_logger, #new_logger

Constructor Details

#initialize(project: nil, zone: nil, out_format: nil, filter: nil, user_name: nil, local_port: nil, ssh_key: nil) ⇒ Cmd

Returns a new instance of Cmd.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 12

def initialize(project: nil, zone: nil, out_format: nil, filter: nil, user_name: nil, local_port: nil, ssh_key: nil)
  super(env: { 'CLOUDSDK_PYTHON_SITEPACKAGES' => '1' })
  require 'net/ssh'
  require 'net/ssh/proxy/command'

  @project = project unless project.nil?
  @zone = zone unless zone.nil?
  @default_out_format = out_format
  @default_filter = filter
  @user_name = user_name
  @local_port = local_port
  @ssh_key = ssh_key
  raise CemAcpt::Platform::CmdError, 'gcloud command not available' unless gcloud?
  raise CemAcpt::Platform::CmdError, 'gcloud is not authenticated' unless authenticated?
end

Instance Method Details

#apply_manifest(instance_name, manifest, opts = {}) ⇒ Object



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 234

def apply_manifest(instance_name, manifest, opts = {})
  unless opts[:apply][:no_upload]
    with_temp_manifest_file(manifest) do |tf|
      upload_temp_manifest(instance_name, tf.path, remote_path: '/tmp/acpt_manifest.pp', opts: opts)
    end
  end
  apply_cmd = [opts[:puppet_path], 'apply', '/tmp/acpt_manifest.pp']
  apply_cmd << '--trace' if opts[:apply][:trace]
  apply_cmd << "--hiera_config=#{opts[:apply][:hiera_config]}" if opts[:apply][:hiera_config]
  apply_cmd << '--debug' if opts[:apply][:debug]
  apply_cmd << '--noop' if opts[:apply][:noop]
  apply_cmd << '--detailed-exitcodes' if opts[:apply][:detailed_exitcodes]

  run_shell(
    instance_name,
    apply_cmd.join(' '),
    opts
  )
end

#delete_instance(instance_name) ⇒ Object

Deletes a GCP VM instance.



110
111
112
113
114
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 110

def delete_instance(instance_name)
  local_exec("compute instances delete #{instance_name} --quiet")
rescue StandardError
  # Ignore errors when deleting instances.
end

#filter(out_filter = nil) ⇒ Object

Returns the filter string passed in during object initialization or the default filter string.



54
55
56
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 54

def filter(out_filter = nil)
  out_filter&.chomp || @default_filter
end

#format(out_format = nil) ⇒ Object

Returns the format string passed in during object initialization or the default format string.



48
49
50
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 48

def format(out_format = nil)
  out_format&.chomp || @default_out_format
end

#local_exec(command, out_format: 'json', out_filter: nil, project_flag: true) ⇒ Object

Runs `gcloud` commands locally.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 88

def local_exec(command, out_format: 'json', out_filter: nil, project_flag: true)
  final_command = format_gcloud_command(command, out_format: out_format, out_filter: out_filter, project_flag: project_flag)
  stdout, stderr, status = Open3.capture3(env, final_command)
  raise "gcloud command '#{final_command}' failed: #{stderr}" unless status.success?

  if format(out_format) == 'json'
    begin
      ::JSON.parse(stdout)
    rescue ::JSON::ParserError
      stdout.chomp
    end
  else
    stdout.chomp
  end
end

#local_portObject



58
59
60
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 58

def local_port
  @local_port ||= rand(49_512..65_535)
end

#projectObject

Returns either the project name passed in during object initialization or the project name from the local config.



30
31
32
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 30

def project
  @project ||= project_from_config&.chomp
end

#project_from_configObject



212
213
214
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 212

def project_from_config
  local_exec('config get-value project', out_format: nil, project_flag: false)
end

#run_shell(instance_name, cmd, opts = {}) ⇒ Object



220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 220

def run_shell(instance_name, cmd, opts = {})
  ssh_opts = opts.key?(:ssh_opts) ? opts[:ssh_opts] : {}
  command = [
    'sudo -n -u root -i',
    cmd,
  ]
  ssh(
    instance_name,
    command.join(' '),
    ignore_command_errors: true,
    opts: ssh_opts,
  )
end

#scp_download(instance_name, remote, local, use_proxy_command: true, scp_opts: {}, opts: {}) ⇒ Object

Downloads a file from the specified VM.



153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 153

def scp_download(instance_name, remote, local, use_proxy_command: true, scp_opts: {}, opts: {})
  raise "Local file #{local} does not exist" unless File.exist?(local)

  cmd = [
    'compute',
    'scp',
  ]
  cmd << '--strict-host-key-checking=no'
  cmd << '--tunnel-through-iap' if use_proxy_command
  cmd << '--recurse' if scp_opts[:recurse]
  cmd << "#{instance_name}:#{remote} #{local}"
  local_exec(cmd.join(' '), out_format: 'json')
end

#scp_upload(instance_name, local, remote, use_proxy_command: true, scp_opts: {}, opts: {}) ⇒ Object

Uploads a file to the specified VM.



138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 138

def scp_upload(instance_name, local, remote, use_proxy_command: true, scp_opts: {}, opts: {})
  raise "Local file #{local} does not exist" unless File.exist?(local)

  cmd = [
    'compute',
    'scp',
  ]
  cmd << '--strict-host-key-checking=no'
  cmd << "--tunnel-through-iap" if use_proxy_command
  cmd << "--recurse" if scp_opts[:recurse]
  cmd << "#{local} #{instance_name}:#{remote}"
  local_exec(cmd.join(' '), out_format: 'json')
end

#ssh(instance_name, cmd, ignore_command_errors: false, use_proxy_command: true, opts: {}) ⇒ Object

This function runs the specified command as the currently authenticated user on the given CGP VM via SSH. Using `ssh` does not invoke the gcloud command and is dependent on the system's SSH configuration.



124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 124

def ssh(instance_name, cmd, ignore_command_errors: false, use_proxy_command: true, opts: {})
  Net::SSH.start(instance_name, user_name,
                 ssh_opts(instance_name: instance_name, use_proxy_command: use_proxy_command, opts: opts)) do |ssh|
    logger.debug("Running SSH command '#{cmd}' on instance '#{instance_name}'")
    result = ssh.exec!(cmd)
    if result.exitstatus != 0 && !ignore_command_errors
      abridged_cmd = cmd.length > 100 ? "#{cmd[0..100]}..." : cmd
      raise "Failed to run SSH command \"#{abridged_cmd}\" on #{instance_name}: #{result}"
    end
    result
  end
end

#ssh_keyObject



62
63
64
65
66
67
68
69
70
71
72
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 62

def ssh_key
  return @ssh_key unless @ssh_key.nil?

  if File.exist?(File.join([ENV['HOME'], '.ssh', 'acpt_test_key']))
    @ssh_key = File.join([ENV['HOME'], '.ssh', 'acpt_test_key'])
  else
    logger.debug("Test SSH key not found at #{File.join([ENV['HOME'], '.ssh', 'acpt_test_key'])}, using default")
    @ssh_key = File.join([ENV['HOME'], '.ssh', 'google_compute_engine'])
  end
  @ssh_key
end

#ssh_opts(instance_name: nil, use_proxy_command: true, opts: {}) ⇒ Object

Returns a formatted hash of ssh options to be used with Net::SSH.start. If you pass in a GCP VM instance name, this method will configure the IAP tunnel ProxyCommand to use. If you pass in an opts hash, it will merge the options with the default options.



78
79
80
81
82
83
84
85
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 78

def ssh_opts(instance_name: nil, use_proxy_command: true, opts: {})
  base_opts = default_ssh_opts
  if use_proxy_command
    base_opts[:proxy] = proxy_command(instance_name, port: base_opts[:port])
    base_opts[:host_key_alias] = vm_alias(instance_name)
  end
  base_opts.merge(opts).reject { |_, v| v.nil? }
end

#ssh_ready?(instance_name, opts: {}) ⇒ Boolean

Returns:

  • (Boolean)


167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 167

def ssh_ready?(instance_name, opts: {})
  ssh_options = ssh_opts(instance_name: instance_name, opts: opts)
  logger.debug("Testing SSH connection to #{instance_name} with options #{ssh_options}")
  gcloud_ssh(instance_name, 'echo "SSH is ready"')
  logger.debug('Removing ecdsa & ed25519 host keys from config due to bug in jruby-openssl')
  gcloud_ssh(instance_name, 'sudo sed -E -i "s;HostKey /etc/ssh/ssh_host_(ecdsa|ed25519)_key;;g" /etc/ssh/sshd_config')
  logger.debug('Restarting SSH service')
  gcloud_ssh(instance_name, 'sudo systemctl restart sshd', ignore_command_errors: true)
  logger.info("SSH connection to #{instance_name} is ready")
  true
rescue StandardError => e
  logger.debug("SSH connection to #{instance_name} failed: #{e}")
  false
end

#stop_instance(instance_name) ⇒ Object

Stops a GCP VM instance.



105
106
107
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 105

def stop_instance(instance_name)
  local_exec("compute instances stop #{instance_name}")
end

#user_nameObject

Returns either the user name passed in during object initialization or queries the current active, authenticated user from gcloud and returns the name.



42
43
44
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 42

def user_name
  @user_name ||= authenticated_user_name
end

#vm_describe(instance_name, out_format: 'json', out_filter: nil) ⇒ Object

Returns a Hash describing a GCP VM instance.



117
118
119
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 117

def vm_describe(instance_name, out_format: 'json', out_filter: nil)
  local_exec("compute instances describe #{instance_name}", out_format: out_format, out_filter: out_filter)
end

#with_iap_tunnel(instance_name, instance_port = 22, disable_connection_check: false, &block) ⇒ Object

This function spawns a background thread to run a GCP IAP tunnel, run the given code block, then kill the thread. The code block will be yielded ssh_opts that are used to configure SSH connections over the IAP tunnel. The IAP tunnel is run in it's own thread and will not block the main thread. The IAP tunnel is killed when the code block exits. All SSH connections made in the code block must be made to the host '127.0.0.1' using the yielded ssh_opts and not the VM name.

Parameters:

  • instance_name (String)

    The name of the GCP VM instance to connect to.

  • instance_port (Integer) (defaults to: 22)

    The port to connect to on the GCP VM instance.

  • disable_connection_check (Boolean) (defaults to: false)

    If true, the connection check will be disabled.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 191

def with_iap_tunnel(instance_name, instance_port = 22, disable_connection_check: false, &block)
  cmd = [
    'compute start-iap-tunnel',
    "#{instance_name} #{instance_port}",
    "--local-host-port=localhost:#{local_port}",
  ]
  cmd << '--disable-connection-check' if disable_connection_check
  tunnel_ssh_opts = {
    proxy: nil,
    port: local_port,
    forward_agent: false,
  }
  final_cmd = format_gcloud_command(cmd.join(' '), out_format: nil, project_flag: false)
  tunnel_pid = Process.spawn(env, final_cmd)
  begin
    block.call(ssh_opts(use_proxy_command: false, opts: tunnel_ssh_opts))
  ensure
    Process.kill('TERM', tunnel_pid)
  end
end

#zoneObject

Returns either the zone name passed in during object initialization or the zone name from the local config.



36
37
38
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 36

def zone
  @zone ||= zone_from_config&.chomp
end

#zone_from_configObject



216
217
218
# File 'lib/cem_acpt/platform/gcp/cmd.rb', line 216

def zone_from_config
  local_exec('config get-value compute/zone', out_format: nil, project_flag: false)
end