AwsEc2Environment

A gem that makes it easier to interact with and deploy Ruby projects that are hosted on EC2 instances in AWS.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add aws_ec2_environment

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install aws_ec2_environment

Usage

Use AwsEc2Environment.from_yaml_file to create a new representation of your EC2 environment from a config file:

ec2_env = AwsEc2Environment.from_yaml_file("./aws.yml", :production)

# this will ensure that any post-connection cleanup is handled, such as terminating
# any SSM port forwarding sessions that are active
at_exit { ec2_env.stop_ssh_port_forwarding_sessions } if ec2_env.config.use_ssm

# this will return a list of hosts for sshing, handling any pre-connection setup
# such as starting port forwarding sessions for each instance if SSM is enabled.
ec2_env.hosts_for_sshing

Configuration

This is the most basic configuration you can have:

production:
  aws_region: ap-southeast-2
  ssh_user: deploy
  filters:
    - name: 'instance-state-name'
      values: ['running']
    - name: 'tag:Name'
      values: ['MyWebsiteProductionAppServerAsg']

All the top level properties are required, and the filters key holds an array of filters that are used with the DescribeInstances API endpoint.

With bastion hosts

You can specify filters for a bastion instance too:

production:
  aws_region: ap-southeast-2
  ssh_user: deploy
  filters:
    - name: 'instance-state-name'
      values: ['running']
    - name: 'tag:Name'
      values: ['MyWebsiteProductionAppServerAsg']
  bastion_instance:
    ssh_user: bastion
    filters:
      - name: 'instance-state-name'
        values: ['running']
      - name: 'tag:Name'
        values: ['MyWebsiteProductionBastionAsg']

Note that the filters should result in one instance being returned, otherwise an error will be thrown.

If you use the same user as your application servers, you can pass an array of filters as the value of the top-level property:

production:
  aws_region: ap-southeast-2
  ssh_user: deploy
  filters:
    - name: 'instance-state-name'
      values: ['running']
    - name: 'tag:Name'
      values: ['MyWebsiteProductionAppServerAsg']
  bastion_instance:
    - name: 'instance-state-name'
      values: ['running']
    - name: 'tag:Name'
      values: ['MyWebsiteProductionBastionAsg']

With SSM

If your instances have the SSM Agent (preinstalled on some AMIs), you can use SSM to connect directly to instances even if they're in a private subnet, via port forwarding:

production:
  aws_region: ap-southeast-2
  ssh_user: deploy
  ssm_host: 'ec2.#{id}.local.ackama.app'
  use_ssm: true
  filters:
    - name: 'instance-state-name'
      values: ['running']
    - name: 'tag:Name'
      values: ['MyWebsiteProductionAppServerAsg']

This requires the aws CLI and session-manager-plugin to be installed locally. These both come preinstalled on GitHub Actions runners, and are otherwise easy to install manually.

You can also specify an alternative hostname to use instead of 127.0.0.1 with the ssm_host property - this is useful when working with tools like Capistrano that only log the host name, so this property can let you ensure each instance can be identified in the logs.

This property should be a host that resolves to 127.0.0.1, and you can inject the instance id with #{id}.

Ackama provides ec2.*.local.ackama.app for this

With Capistrano

# ./Capfile
# ...
require "aws_ec2_environment"

# ./config/deploy/production.rb
set :rails_env, "production"
set :branch, "production"

ec2_env = AwsEc2Environment.from_yaml_file("./aws.yml", :production)

at_exit { ec2_env.stop_ssh_port_forwarding_sessions } if ec2_env.config.use_ssm

ssh_options = {}

if ec2_env.use_bastion_server?
  ssh_options[:proxy] = Net::SSH::Proxy::Command.new(ec2_env.build_ssh_bastion_proxy_command)
end

set :ssh_options, ssh_options

role(:app, ec2_env.hosts_for_sshing, user: ec2_env.config.ssh_user)

With custom port forwarding

You can also use the SsmPortForwardingSession class directly to do port forwarding, which can be useful for things like custom rake tasks:

require "aws_ec2_environment"

task :forward_port, %i[instance_id remote_port local_port] => :environment do |_, args|
  # trap ctl+c to make things a bit nicer (otherwise we'll get an ugly stacktrace)
  # since we expect this to be used to terminate the command
  trap("SIGINT") { exit }

  logger = Logger.new($stdout)

  instance_id = args.fetch(:instance_id)
  remote_port = args.fetch(:remote_port)
  local_port = args.fetch(:local_port, nil)

  session = AwsEc2Environment::SsmPortForwardingSession.new(
    instance_id,
    remote_port,
    local_port:,
    logger:
  )

  at_exit { session.close }

  local_port = session.wait_for_local_port

  local_alias = "ec2.#{instance_id}.local.ackama.app:#{local_port}"
  logger.info "Use #{local_alias} to communicate with port #{remote_port} on #{instance_id}"

  loop { sleep 1 }
end

AWS Authentication and Permissions

Since this gem interacts with AWS, it must be configured with credentials - see here for how to do that.

We recommend using OpenID Connect to authenticate with AWS when running in GitHub Actions.

The credentials must be for an identity that is allowed to perform the ec2:DescribeInstances action. If you're using SSM you must also allow the ssm:StartSession and ssm:TerminateSession actions.

Here is a sample IAM policy document that grants these actions conditionally in accordance with the principle of least privilege:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowDescribingInstances",
      "Effect": "Allow",
      "Action": "ec2:DescribeInstances",
      "Resource": "*"
    },
    {
      "Sid": "AllowStartingPortForwardingSessions",
      "Effect": "Allow",
      "Action": "ssm:StartSession",
      "Resource": "arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
    },
    {
      "Sid": "AllowStartingNewSessionsOnTaggedEC2Instances",
      "Effect": "Allow",
      "Action": "ssm:StartSession",
      "Resource": "arn:aws:ec2:*:account-id:instance/*",
      "Condition": {
        "StringEquals": {
          "ssm:resourceTag/Environment": "Production",
          "ssm:resourceTag/Name": "MyWebsiteProductionAppServerAsg"
        }
      }
    },
    {
      "Sid": "AllowTerminatingOwnSessions",
      "Effect": "Allow",
      "Action": "ssm:TerminateSession",
      "Resource": "arn:aws:ssm:*:account-id:session/*",
      "Condition": {
        "StringLike": {
          "ssm:resourceTag/aws:ssmmessages:session-id": "${aws:username}"
        }
      }
    }
  ]
}

Remember to replace "account-id" in the above document with the ID of your AWS account!

If you are using a federated identity (such as GitHub's OpenID Connect provider), then you will need to replace ${aws:username} with ${aws:userid} - see here for more.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Contributions are welcome. Please see the contribution guidelines for detailed instructions.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in this project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.