diff --git a/Gemfile b/Gemfile index 7fb1127a..3c6fe90e 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'rake', require: false group :test do gem 'codeclimate-test-reporter' gem 'pry' + gem 'rexml' gem 'rubocop' end diff --git a/lib/moonshot/controller.rb b/lib/moonshot/controller.rb index cc81250e..070b3edf 100644 --- a/lib/moonshot/controller.rb +++ b/lib/moonshot/controller.rb @@ -186,10 +186,13 @@ def ssh @config.ssh_instance ||= SSHTargetSelector.new( stack, asg_name: @config.ssh_auto_scaling_group_name ).choose! - cb = SSHCommandBuilder.new(@config.ssh_config, @config.ssh_instance) + teleport_config = TeleportConfig.new( + stack.name, @config.ssh_config.ssh_user, ENV['AWS_REGION'] + ) + cb = SSHCommandBuilder.new(@config.ssh_config, @config.ssh_instance, teleport_config) result = cb.build(@config.ssh_command) - warn "Opening SSH connection to #{@config.ssh_instance} (#{result.ip})..." + warn "Opening SSH connection to #{@config.ssh_instance} (#{result.host})..." exec(result.cmd) end diff --git a/lib/moonshot/ssh_command_builder.rb b/lib/moonshot/ssh_command_builder.rb index 40829e6f..9004f2bd 100644 --- a/lib/moonshot/ssh_command_builder.rb +++ b/lib/moonshot/ssh_command_builder.rb @@ -3,33 +3,30 @@ require 'shellwords' module Moonshot - # Create an ssh command from configuration. + # Create a tsh ssh command from configuration. class SSHCommandBuilder - Result = Struct.new(:cmd, :ip) + Result = Struct.new(:cmd, :host) - def initialize(ssh_config, instance_id) - @config = ssh_config - @instance_id = instance_id + def initialize(ssh_config, instance_id, teleport_config) + @config = ssh_config + @instance_id = instance_id + @teleport_config = teleport_config end def build(command = nil) - cmd = ['ssh', '-t'] + cmd = ['tsh', 'ssh'] cmd << @config.ssh_options if @config.ssh_options - cmd << "-i #{@config.ssh_identity_file}" if @config.ssh_identity_file - cmd << "-l #{@config.ssh_user}" if @config.ssh_user - cmd << instance_ip + cmd << "--proxy=#{@teleport_config.proxy_url}" + cmd << "-i #{@teleport_config.identity_file}" if @teleport_config.bot_user? + cmd << "#{@teleport_config.ssh_user}@#{instance_host}" cmd << Shellwords.escape(command) if command - Result.new(cmd.join(' '), instance_ip) + Result.new(cmd.join(' '), instance_host) end private - def instance_ip - @instance_ip ||= Aws::EC2::Client.new - .describe_instances(instance_ids: [@instance_id]) - .reservations.first.instances.first.public_ip_address - rescue StandardError - raise "Failed to determine public IP address for instance #{@instance_id}!" + def instance_host + @instance_host ||= @teleport_config.host_for(@instance_id) end end end diff --git a/lib/moonshot/teleport_config.rb b/lib/moonshot/teleport_config.rb new file mode 100644 index 00000000..b1a9015c --- /dev/null +++ b/lib/moonshot/teleport_config.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Moonshot + # Encapsulates Teleport SSH configuration derived from the stack name, + # SSH user, and AWS region. Determines the correct proxy URL, account ID, + # and identity file path for both normal users and bot users. + class TeleportConfig + PROD_ACCOUNT_ID = '546349603759' + DEV_ACCOUNT_ID = '672327909798' + BOT_USER = 'clouddatabot' + + PROD_PROXY_TEMPLATE = '%s.teleport.cloudservices.acquia.io' + DEV_PROXY = 'teleport.dev.cloudservices.acquia.io' + + PROD_IDENTITY_TEMPLATE = '/opt/machine-id/%s/identity' + DEV_IDENTITY = '/opt/machine-id/dev-us-east-1/identity' + + attr_reader :proxy_url, :account_id, :region, :ssh_user + + def initialize(stack_name, ssh_user, region) + @stack_name = stack_name.to_s + @ssh_user = ssh_user.to_s + @region = region.to_s + + if prod? + @account_id = PROD_ACCOUNT_ID + @proxy_url = format(PROD_PROXY_TEMPLATE, region: @region) + else + @account_id = DEV_ACCOUNT_ID + @proxy_url = DEV_PROXY + end + end + + def bot_user? + @ssh_user == BOT_USER + end + + # Returns the Teleport identity file path for bot users; nil for normal users. + def identity_file + return nil unless bot_user? + + prod? ? format(PROD_IDENTITY_TEMPLATE, region: @region) : DEV_IDENTITY + end + + # Constructs the Teleport node name used as SSH hostname. + # Format: .. + def host_for(instance_id) + "#{instance_id}.#{@region}.#{@account_id}" + end + + private + + def prod? + @stack_name.include?('prod') + end + end +end diff --git a/lib/plugins/rotate_asg_instances.rb b/lib/plugins/rotate_asg_instances.rb index d8a341c4..50e8b283 100644 --- a/lib/plugins/rotate_asg_instances.rb +++ b/lib/plugins/rotate_asg_instances.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require 'aws-sdk' +require 'aws-sdk-autoscaling' +require 'aws-sdk-ec2' module Moonshot module Plugins diff --git a/lib/plugins/rotate_asg_instances/asg.rb b/lib/plugins/rotate_asg_instances/asg.rb index 4031c9e2..0e5627ee 100644 --- a/lib/plugins/rotate_asg_instances/asg.rb +++ b/lib/plugins/rotate_asg_instances/asg.rb @@ -7,7 +7,7 @@ class ASG # rubocop:disable Metrics/ClassLength def initialize(resources) @resources = resources - @ssh = Moonshot::RotateAsgInstances::SSH.new + @ssh = Moonshot::RotateAsgInstances::SSH.new(@resources) @ilog = @resources.ilog end diff --git a/lib/plugins/rotate_asg_instances/ssh.rb b/lib/plugins/rotate_asg_instances/ssh.rb index ae29067a..1a41aee1 100644 --- a/lib/plugins/rotate_asg_instances/ssh.rb +++ b/lib/plugins/rotate_asg_instances/ssh.rb @@ -10,6 +10,10 @@ def initialize(response) end class SSH + def initialize(resources) + @resources = resources + end + # As per the standard it is raising correctly but still giving an error. def test_ssh_connection(instance_id) Retriable.retriable(base_interval: 5, tries: 3) do @@ -29,7 +33,12 @@ def exec(command, instance_id) private def build_command(command, instance_id) - cb = SSHCommandBuilder.new(Moonshot.config.ssh_config, instance_id) + teleport_config = Moonshot::TeleportConfig.new( + @resources.controller.stack.name, + Moonshot.config.ssh_config.ssh_user, + ENV['AWS_REGION'] + ) + cb = SSHCommandBuilder.new(Moonshot.config.ssh_config, instance_id, teleport_config) cb.build(command).cmd end end diff --git a/spec/moonshot/plugins/rotate_asg_instances/asg_spec.rb b/spec/moonshot/plugins/rotate_asg_instances/asg_spec.rb index 76e54af5..c00b44b5 100644 --- a/spec/moonshot/plugins/rotate_asg_instances/asg_spec.rb +++ b/spec/moonshot/plugins/rotate_asg_instances/asg_spec.rb @@ -113,26 +113,32 @@ def stub_cf_client end describe '#shutdown_instance' do - let(:public_ip_address) { '10.234.32.21' } let(:instance) { instance_double(Aws::EC2::Instance) } let(:command_builder) { Moonshot::SSHCommandBuilder } subject { super().send(:shutdown_instance, instance_id) } before(:each) do + ENV['AWS_REGION'] = 'us-east-1' moonshot_config.ssh_config.ssh_user = 'ci_user' moonshot_config.ssh_config.ssh_options = ssh_options allow(Aws::EC2::Instance).to receive(:new).and_return(instance) - allow_any_instance_of(command_builder).to receive(:instance_ip).and_return(public_ip_address) + allow(instance).to receive(:exists?).and_return(true) + allow(instance).to receive(:state).and_return({ name: 'running' }) allow(instance).to receive(:wait_until_stopped) + allow(ilog).to receive(:info) end + after(:each) do + ENV.delete('AWS_REGION') + end context 'when ssh_options are not defined' do let(:ssh_options) { nil } it 'issues a shutdown without options to the instance' do expect_any_instance_of(ssh_executor).to receive(:run).with( - "ssh -t -l #{moonshot_config.ssh_config.ssh_user} #{public_ip_address} sudo\\ shutdown\\ -h\\ now" + 'tsh ssh --proxy=teleport.dev.cloudservices.acquia.io -tA ' \ + "ci_user@#{instance_id}.us-east-1.672327909798 sudo\\ shutdown\\ -h\\ now" ) subject end @@ -143,8 +149,9 @@ def stub_cf_client it 'issues a shutdown with options to the instance' do expect_any_instance_of(ssh_executor).to receive(:run).with( - 'ssh -t -v -o UserKnownHostsFile=/dev/null ' \ - "-l ci_user #{public_ip_address} sudo\\ shutdown\\ -h\\ now" + 'tsh ssh -v -o UserKnownHostsFile=/dev/null ' \ + '--proxy=teleport.dev.cloudservices.acquia.io -tA ' \ + "ci_user@#{instance_id}.us-east-1.672327909798 sudo\\ shutdown\\ -h\\ now" ) subject end diff --git a/spec/moonshot/plugins/rotate_asg_instances/ssh_spec.rb b/spec/moonshot/plugins/rotate_asg_instances/ssh_spec.rb index f6f897ee..dd3c7eb6 100644 --- a/spec/moonshot/plugins/rotate_asg_instances/ssh_spec.rb +++ b/spec/moonshot/plugins/rotate_asg_instances/ssh_spec.rb @@ -29,7 +29,7 @@ let(:config) { resources.controller.config } - subject { described_class.new } + subject { described_class.new(resources) } describe '#test_ssh_connection' do it 'raise error if #test_ssh_connection fails' do diff --git a/spec/moonshot/ssh_spec.rb b/spec/moonshot/ssh_spec.rb index 56423c3d..4ace7bec 100644 --- a/spec/moonshot/ssh_spec.rb +++ b/spec/moonshot/ssh_spec.rb @@ -4,28 +4,33 @@ c.app_name = 'MyApp' c.environment_name = 'prod' c.ssh_config.ssh_user = 'joeuser' - c.ssh_config.ssh_identity_file = '/Users/joeuser/.ssh/thegoods.key' c.ssh_command = 'cat /etc/passwd' Moonshot::Controller.new(c) end + let(:stack_double) { instance_double(Moonshot::Stack, name: 'MyApp-prod') } + describe 'Moonshot::Controller#ssh' do before(:each) do ENV.delete('MOONSHOT_SSH_OPTIONS') + ENV['AWS_REGION'] = 'us-east-1' + allow(subject).to receive(:stack).and_return(stack_double) + end + + after(:each) do + ENV.delete('AWS_REGION') end context 'normally' do - it 'should execute an ssh command with proper parameters' do + it 'should execute a tsh ssh command with proper parameters' do ts = instance_double(Moonshot::SSHTargetSelector) expect(Moonshot::SSHTargetSelector).to receive(:new).and_return(ts) expect(ts).to receive(:choose!).and_return('i-04683a82f2dddcc04') - expect_any_instance_of(Moonshot::SSHCommandBuilder).to receive(:instance_ip).exactly(2) - .and_return('123.123.123.123') expect(subject).to receive(:exec) - .with('ssh -t -i /Users/joeuser/.ssh/thegoods.key -l joeuser 123.123.123.123 cat\ /etc/passwd') # rubocop:disable LineLength + .with('tsh ssh --proxy=us-east-1.teleport.cloudservices.acquia.io -tA joeuser@i-04683a82f2dddcc04.us-east-1.546349603759 cat\ /etc/passwd') # rubocop:disable LineLength expect { subject.ssh } - .to output("Opening SSH connection to i-04683a82f2dddcc04 (123.123.123.123)...\n") + .to output("Opening SSH connection to i-04683a82f2dddcc04 (i-04683a82f2dddcc04.us-east-1.546349603759)...\n") .to_stderr end end @@ -37,13 +42,11 @@ c end - it 'should execute an ssh command with proper parameters' do - expect_any_instance_of(Moonshot::SSHCommandBuilder).to receive(:instance_ip).exactly(2) - .and_return('123.123.123.123') + it 'should execute a tsh ssh command with proper parameters' do expect(subject).to receive(:exec) - .with('ssh -t -i /Users/joeuser/.ssh/thegoods.key -l joeuser 123.123.123.123 cat\ /etc/passwd') # rubocop:disable LineLength + .with('tsh ssh --proxy=us-east-1.teleport.cloudservices.acquia.io -tA joeuser@i-012012012012012.us-east-1.546349603759 cat\ /etc/passwd') # rubocop:disable LineLength expect { subject.ssh } - .to output("Opening SSH connection to i-012012012012012 (123.123.123.123)...\n").to_stderr + .to output("Opening SSH connection to i-012012012012012 (i-012012012012012.us-east-1.546349603759)...\n").to_stderr end end end diff --git a/spec/moonshot/teleport_config_spec.rb b/spec/moonshot/teleport_config_spec.rb new file mode 100644 index 00000000..ec538761 --- /dev/null +++ b/spec/moonshot/teleport_config_spec.rb @@ -0,0 +1,75 @@ +describe Moonshot::TeleportConfig do + let(:instance_id) { 'i-0036e48e43b79740f' } + + describe 'prod environment (stack name contains "prod")' do + subject { described_class.new('myapp-prod-us-east-1', 'joeuser', 'us-east-1') } + + it 'uses region-based proxy URL' do + expect(subject.proxy_url).to eq('us-east-1.teleport.cloudservices.acquia.io') + end + + it 'uses the prod account ID' do + expect(subject.account_id).to eq('546349603759') + end + + it 'builds the Teleport hostname correctly' do + expect(subject.host_for(instance_id)).to eq( + 'i-0036e48e43b79740f.us-east-1.546349603759' + ) + end + + it 'is not a bot user for a normal user' do + expect(subject.bot_user?).to be false + end + + it 'returns nil identity_file for normal user' do + expect(subject.identity_file).to be_nil + end + + context 'with bot user (clouddatabot)' do + subject { described_class.new('myapp-prod-us-east-1', 'clouddatabot', 'us-east-1') } + + it 'is identified as a bot user' do + expect(subject.bot_user?).to be true + end + + it 'uses the region-based identity file path' do + expect(subject.identity_file).to eq('/opt/machine-id/us-east-1/identity') + end + end + end + + describe 'dev environment (stack name does not contain "prod")' do + subject { described_class.new('myapp-dev-jsmith', 'joeuser', 'us-east-1') } + + it 'uses the shared dev proxy URL' do + expect(subject.proxy_url).to eq('teleport.dev.cloudservices.acquia.io') + end + + it 'uses the dev account ID' do + expect(subject.account_id).to eq('672327909798') + end + + it 'builds the Teleport hostname correctly' do + expect(subject.host_for(instance_id)).to eq( + 'i-0036e48e43b79740f.us-east-1.672327909798' + ) + end + + it 'is not a bot user for a normal user' do + expect(subject.bot_user?).to be false + end + + context 'with bot user (clouddatabot)' do + subject { described_class.new('myapp-dev-jsmith', 'clouddatabot', 'us-east-1') } + + it 'is identified as a bot user' do + expect(subject.bot_user?).to be true + end + + it 'uses the dev identity file path' do + expect(subject.identity_file).to eq('/opt/machine-id/dev-us-east-1/identity') + end + end + end +end