Skip to content

Commit b934267

Browse files
author
Thomas Heinen
committed
Initial commit
0 parents  commit b934267

File tree

10 files changed

+311
-0
lines changed

10 files changed

+311
-0
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Gemfile.local
2+
Gemfile.local.lock
3+
Gemfile.lock
4+
.bundle
5+
*.gem

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## Version 0.1.0
4+
5+
- Initial version

Gemfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source "https://rubygems.org"
2+
3+
gemspec
4+
5+
group :development do
6+
gem "pry"
7+
gem "bundler"
8+
gem "rake"
9+
gem "chefstyle"
10+
end

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# train-awsssm - Train Plugin for using AWS Systems Manager Agent
2+
3+
This plugin allows applications that rely on Train to communicate via AWS SSM.
4+
5+
## Requirements
6+
7+
The instance in question must run on AWS and you need to have all AWS credentials
8+
set up for the shell which executes the command. Please check the [AWS documentation](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)
9+
for appropriate configuration files and environment variables.
10+
11+
You need the [SSM agent to be installed](https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-agent.html) on the machine (most current AMIs already
12+
have this integrated) and the machine needs to have the managed policy
13+
`AmazonSSMManagedInstanceCore` or a least privilege equivalent attached as
14+
IAM profile.
15+
16+
Commands will be executed under the `ssm-user` user.
17+
18+
## Installation
19+
20+
You will have to build this gem yourself to install it as it is not yet on
21+
Rubygems.Org. For this there is a rake task which makes this a one-liner:
22+
23+
```bash
24+
rake install:local
25+
```
26+
27+
## Transport parameters
28+
29+
| Option | Explanation | Default |
30+
| -------------------- | --------------------------------------------- | ---------------- |
31+
| `host` | IP or DNS name of instance | (required) |
32+
| `execution_timeout` | Maximum time until timeout | 60 |
33+
| `recheck_invocation` | Interval of rechecking AWS command invocation | 1.0 |
34+
| `recheck_execution` | Interval of rechecking completion of command | 1.0 |
35+
36+
## Example use
37+
38+
```ruby
39+
require "train-awsssm"
40+
train = Train.create("awsssm", {
41+
host: '172.16.3.12',
42+
logger: Logger.new($stdout, level: :info)
43+
})
44+
conn = train.connection
45+
result = conn.run_command("apt upgrade -y")
46+
conn.close
47+
```

Rakefile

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# A Rakefile defines tasks to help maintain your project.
2+
# Rake provides several task templates that are useful.
3+
4+
# require "rspec/core/rake_task"
5+
require "bump/tasks"
6+
require "bundler/gem_tasks"
7+
8+
#------------------------------------------------------------------#
9+
# Rake Default Task
10+
#------------------------------------------------------------------#
11+
12+
# Do not run integration by default
13+
task default: %I{test:unit test:functional}
14+
15+
#------------------------------------------------------------------#
16+
# Test Runner Tasks
17+
#------------------------------------------------------------------#
18+
require "rake/testtask"
19+
20+
namespace :test do
21+
{
22+
unit: "test/unit/*_test.rb",
23+
functional: "test/integration/*_test.rb",
24+
integration: "test/function/*_test.rb",
25+
}.each do |task_name, glob|
26+
Rake::TestTask.new(task_name) do |t|
27+
t.libs.push "lib"
28+
t.libs.push "test"
29+
t.test_files = FileList[glob]
30+
t.verbose = true
31+
t.warning = false
32+
end
33+
end
34+
end
35+
36+
# #------------------------------------------------------------------#
37+
# # Code Style Tasks
38+
# #------------------------------------------------------------------#
39+
require "chefstyle"
40+
require "rubocop/rake_task"
41+
RuboCop::RakeTask.new(:lint) do |task|
42+
task.options << "--display-cop-names"
43+
end

lib/train-awsssm.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
libdir = File.dirname(__FILE__)
2+
$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
3+
4+
require "train-awsssm/version"
5+
6+
require "train-awsssm/transport"
7+
require "train-awsssm/connection"

lib/train-awsssm/connection.rb

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
require "aws-sdk-ec2"
2+
require "aws-sdk-ssm"
3+
require "resolv"
4+
require "train"
5+
6+
module TrainPlugins
7+
module AWSSSM
8+
class Connection < Train::Plugins::Transport::BaseConnection
9+
def initialize(options)
10+
super(options)
11+
12+
@ssm = Aws::SSM::Client.new
13+
end
14+
15+
def close
16+
logger.info format("[AWS-SSM] Closed connection to %s", @options[:host])
17+
end
18+
19+
def uri
20+
"aws-ssm://#{@options[:host]}/"
21+
end
22+
23+
def run_command_via_connection(cmd, &data_handler)
24+
logger.info format("[AWS-SSM] Sending command to %s", @options[:host])
25+
exit_status, stdout, stderr = execute_on_channel(cmd, &data_handler)
26+
27+
CommandResult.new(stdout, stderr, exit_status)
28+
end
29+
30+
def execute_on_channel(cmd, &data_handler)
31+
logger.debug format("[AWS-SSM] Command: '%s'", cmd)
32+
33+
result = execute_command(@options[:host], cmd)
34+
35+
stdout = result.standard_output_content || ""
36+
stderr = result.standard_error_content || ""
37+
exit_status = result.response_code
38+
39+
[exit_status, stdout, stderr]
40+
end
41+
42+
private
43+
44+
# Check if this is an IP address
45+
def ip_address?(address)
46+
!!(address =~ Resolv::IPv4::Regex)
47+
end
48+
49+
# Check if this is a DNS name
50+
def dns_name?(address)
51+
!ip_address?(address)
52+
end
53+
54+
# Check if this is an internal/external AWS DNS entry
55+
def amazon_dns?(dns)
56+
dns.end_with?(".compute.amazonaws.com") || dns.end_with?(".compute.internal")
57+
end
58+
59+
# Resolve EC2 instance ID associated with a primary IP or a DNS entry
60+
def instance_id(address)
61+
logger.debug format("[AWS-SSM] Trying to resolve address %s", address)
62+
63+
ec2 = Aws::EC2::Client.new
64+
instances = ec2.describe_instances.reservations.collect { |r| r.instances.first }
65+
66+
# Resolve, if DNS name and not Amazon default
67+
if dns_name?(address) && !amazon_dns?(address)
68+
address = Resolv.getaddress(address)
69+
logger.debug format("[AWS-SSM] Resolved non-internal AWS address to %s", address)
70+
end
71+
72+
# Check the primary IPs and hostnames for a match
73+
id = instances.detect do |i|
74+
[
75+
i.private_ip_address,
76+
i.public_ip_address,
77+
i.private_dns_name,
78+
i.public_dns_name,
79+
].include?(address)
80+
end&.instance_id
81+
82+
raise format("Could not resolve instance ID for address %s", address) if id.nil?
83+
84+
logger.debug format("[AWS-SSM] Resolved address %s to instance ID %s", address, id)
85+
id
86+
end
87+
88+
# Request a command invocation and wait until it is registered with an ID
89+
def wait_for_invocation(instance_id, command_id)
90+
invocation_result(instance_id, command_id)
91+
92+
# Retry until the invocation was created on AWS
93+
rescue Aws::SSM::Errors::InvocationDoesNotExist
94+
sleep @options[:recheck_invocation]
95+
retry
96+
end
97+
98+
# Return the result of a given command invocation
99+
def invocation_result(instance_id, command_id)
100+
@ssm.get_command_invocation(instance_id: instance_id, command_id: command_id)
101+
end
102+
103+
# Return if a non-terminal command status was given
104+
# @see https://docs.aws.amazon.com/systems-manager/latest/userguide/monitor-commands.html
105+
def in_progress?(name)
106+
%w{Pending InProgress Delayed}.include? name
107+
end
108+
109+
# Return if a terminal command status was given
110+
# @see https://docs.aws.amazon.com/systems-manager/latest/userguide/monitor-commands.html
111+
def terminal_state?(name)
112+
!in_progress?(name)
113+
end
114+
115+
# Execute a command via SSM
116+
def execute_command(address, command)
117+
instance_id = instance_id(address)
118+
119+
cmd = @ssm.send_command(instance_ids: [instance_id], document_name: "AWS-RunShellScript", parameters: { "commands": [command] })
120+
cmd_id = cmd.command.command_id
121+
122+
wait_for_invocation(instance_id, cmd_id)
123+
logger.debug format("[AWS-SSM] Execution ID %s", cmd_id)
124+
125+
start_time = Time.now
126+
result = invocation_result(instance_id, cmd.command.command_id)
127+
128+
until terminal_state?(result.status) || Time.now - start_time > @options[:execution_timeout]
129+
result = invocation_result(instance_id, cmd.command.command_id)
130+
sleep @options[:recheck_execution]
131+
end
132+
133+
if Time.now - start_time > @options[:execution_timeout]
134+
raise format("Timeout waiting for execution")
135+
elsif result.status != "Success"
136+
raise format('Execution failed with state "%s": %s', result.status, result.standard_error_content || "unknown")
137+
end
138+
139+
result
140+
end
141+
end
142+
end
143+
end

lib/train-awsssm/transport.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
require "train-awsssm/connection"
2+
3+
module TrainPlugins
4+
module AWSSSM
5+
class Transport < Train.plugin(1)
6+
name "awsssm"
7+
8+
option :host, required: true
9+
10+
option :execution_timeout, default: 60.0
11+
option :recheck_invocation, default: 1.0
12+
option :recheck_execution, default: 1.0
13+
14+
def connection(_instance_opts = nil)
15+
@connection ||= TrainPlugins::AWSSSM::Connection.new(@options)
16+
end
17+
end
18+
end
19+
end

lib/train-awsssm/version.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module TrainPlugins
2+
module AWSSSM
3+
VERSION = "0.1.0".freeze
4+
end
5+
end

train-awsssm.gemspec

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
lib = File.expand_path("../lib", __FILE__)
2+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3+
require "train-awsssm/version"
4+
5+
Gem::Specification.new do |spec|
6+
spec.name = "train-awsssm"
7+
spec.version = TrainPlugins::AWSSSM::VERSION
8+
spec.authors = ["Thomas Heinen"]
9+
spec.email = ["theinen@tecracer.de"]
10+
spec.summary = "Train Transport for AWS Systems Manager Agents"
11+
spec.description = "Train plugin to use the AWS Systems Manager Agent to execute commands on machines without SSH/WinRM "
12+
spec.homepage = "https://github.com/tecracer_theinen/train-awsssm"
13+
spec.license = "Apache-2.0"
14+
15+
spec.files = %w{
16+
README.md train-awsssm.gemspec Gemfile
17+
} + Dir.glob(
18+
"lib/**/*", File::FNM_DOTMATCH
19+
).reject { |f| File.directory?(f) }
20+
spec.require_paths = ["lib"]
21+
22+
spec.add_dependency "train", "~> 2.0"
23+
spec.add_dependency "aws-sdk-ec2", "~> 1.129"
24+
spec.add_dependency "aws-sdk-ssm", "~> 1.69"
25+
26+
spec.add_development_dependency "bump", "~> 0.8"
27+
end

0 commit comments

Comments
 (0)