train-awsssm 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c80692d5264b12a209c446350e189224beafd77f022b3b83a5be863120fa2830
4
- data.tar.gz: 63d5691bf13b81f8819224d44b6b5a650deb9b9466135d5170fe4549254b7a16
3
+ metadata.gz: ef5a20eca471f5a767da71380b22425fd4676d3a435b0da83077cd3d14fc2c2f
4
+ data.tar.gz: 97f8b4e0d98b1a20f23b58df148696dceda51843da9fdacd51b274fbf3c94af1
5
5
  SHA512:
6
- metadata.gz: 302e26f18826f458ff0ffdab020ee15533a269fdd5364e17b04a0e7698c49d4592fe94dcbc7b0b8a48fefa14a1daf717e449b9a84d59e6b67563bb9362b83a13
7
- data.tar.gz: 5eed935063c8024ef395e0fbfd4f31143412ef926b788f2c84df748a828c41d71e0f2122be974ea18e6045e49ee4a1998a3bc0af9e2b015f75c7211d0455b719
6
+ metadata.gz: 19cb91506eb8d0b5db6eeb49f6e94ab071616dca46ebeeac187065958a954d8ef9c2e7a80c2e3b72de3d914f252132e25f75e6b2b43e08467fe9eddb2c230b54
7
+ data.tar.gz: c81a2c12459fc95e44c451cf7be89f1c1723ce9820c2790e7963b2676384a8623234f84657d4ddbce8316a03926ee73402bc5640bb5b01cd2d33dfcd3c15060e
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## Version 0.2.0
6
+
7
+ - Add more parameter and status checks
8
+ - Add InSpec support by properly handling failing commands
9
+ - Add Windows support
10
+ - Refactor code
11
+
5
12
  ## Version 0.1.1
6
13
 
7
14
  - Add support for passing in an instance-id instead of IP/DNS
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # train-awsssm - Train Plugin for using AWS Systems Manager Agent
2
2
 
3
- This plugin allows applications that rely on Train to communicate via AWS SSM with Linux instances.
4
-
5
- Windows is currently not yet supported
3
+ This plugin allows applications that rely on Train to communicate via AWS SSM with Linux/Windows instances.
6
4
 
7
5
  ## Requirements
8
6
 
@@ -10,7 +8,7 @@ The instance in question must run on AWS and you need to have all AWS credential
10
8
 
11
9
  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 have this integrated) and the machine needs to have the managed policy `AmazonSSMManagedInstanceCore` or a least privilege equivalent attached as IAM profile.
12
10
 
13
- Commands will be executed under the `root` user.
11
+ Commands will be executed under the `root`/`Administrator` users.
14
12
 
15
13
  ## Installation
16
14
 
@@ -20,12 +18,13 @@ You can build and install this gem on your local system as well via a Rake task:
20
18
 
21
19
  ## Transport parameters
22
20
 
23
- | Option | Explanation | Default |
24
- | -------------------- | --------------------------------------------- | ---------------- |
25
- | `host` | IP, DNS name or EC2 ID of instance | (required) |
26
- | `execution_timeout` | Maximum time until timeout | 60 |
27
- | `recheck_invocation` | Interval of rechecking AWS command invocation | 1.0 |
28
- | `recheck_execution` | Interval of rechecking completion of command | 1.0 |
21
+ | Option | Explanation | Default |
22
+ | -------------------- | ------------------------------------------------- | ---------------- |
23
+ | `host` | IP, DNS name or EC2 ID of instance | (required) |
24
+ | `mode` | Mode for connection, only 'run-command' currently | run-command |
25
+ | `execution_timeout` | Maximum time until timeout | 60 |
26
+ | `recheck_invocation` | Interval of rechecking AWS command invocation | 1.0 |
27
+ | `recheck_execution` | Interval of rechecking completion of command | 1.0 |
29
28
 
30
29
  ## Limitations
31
30
 
@@ -6,24 +6,25 @@ require "train"
6
6
  module TrainPlugins
7
7
  module AWSSSM
8
8
  class Connection < Train::Plugins::Transport::BaseConnection
9
+ attr_reader :instance_id, :options
10
+ attr_writer :ssm, :ec2
11
+
9
12
  def initialize(options)
10
13
  super(options)
11
14
 
12
15
  check_options
13
-
14
- @ssm = Aws::SSM::Client.new
15
16
  end
16
17
 
17
18
  def close
18
- logger.info format("[AWS-SSM] Closed connection to %s", @options[:host])
19
+ logger.info format("[AWS-SSM] Closed connection to %s", options[:host])
19
20
  end
20
21
 
21
22
  def uri
22
- "aws-ssm://#{@options[:host]}/"
23
+ "aws-ssm://#{options[:host]}/"
23
24
  end
24
25
 
25
26
  def run_command_via_connection(cmd, &data_handler)
26
- logger.info format("[AWS-SSM] Sending command to %s", @options[:host])
27
+ logger.info format("[AWS-SSM] Sending command to %s", options[:host])
27
28
  exit_status, stdout, stderr = execute_on_channel(cmd, &data_handler)
28
29
 
29
30
  CommandResult.new(stdout, stderr, exit_status)
@@ -32,7 +33,7 @@ module TrainPlugins
32
33
  def execute_on_channel(cmd, &data_handler)
33
34
  logger.debug format("[AWS-SSM] Command: '%s'", cmd)
34
35
 
35
- result = execute_command(@options[:host], cmd)
36
+ result = execute_command(options[:host], cmd)
36
37
 
37
38
  stdout = result.standard_output_content || ""
38
39
  stderr = result.standard_error_content || ""
@@ -43,26 +44,47 @@ module TrainPlugins
43
44
 
44
45
  private
45
46
 
46
- # Check if this is an IP address
47
- def ip_address?(address)
48
- !!(address =~ Resolv::IPv4::Regex)
47
+ # Return Systems Manager API client
48
+ #
49
+ # @return Aws::SSM::Client
50
+ def ssm
51
+ @ssm ||= ::Aws::SSM::Client.new
49
52
  end
50
53
 
51
- # Check if this is a DNS name
52
- def dns_name?(address)
53
- !ip_address?(address)
54
+ # Return EC2 API client
55
+ #
56
+ # @return Aws::EC2::Client
57
+ def ec2
58
+ @ec2 ||= ::Aws::EC2::Client.new
54
59
  end
55
60
 
56
- # Check if this is an internal/external AWS DNS entry
57
- def amazon_dns?(dns)
58
- dns.end_with?(".compute.amazonaws.com") || dns.end_with?(".compute.internal")
61
+ # Check if options are as needed
62
+ #
63
+ # @raise [ArgumentError] if any options were incorrectly configured
64
+ def check_options
65
+ unless options[:host]
66
+ raise ArgumentError, format("Missing required option :host for train-awsssm")
67
+ end
68
+
69
+ unless supported_modes.include? options[:mode]
70
+ raise ArgumentError, format("Wrong mode `%s`, supported: %s", options[:mode], supported_modes.join(", "))
71
+ end
72
+
73
+ address = options[:host]
74
+ @instance_id = address.start_with?("i-") ? address : resolve_instance_id(address)
75
+
76
+ raise ArgumentError, format("Instance %s is not running", instance_id) unless instance_running?
77
+ raise ArgumentError, format("Instance %s is not managed by SSM or agent unreachable", instance_id) unless managed_instance?
59
78
  end
60
79
 
61
80
  # Resolve EC2 instance ID associated with a primary IP or a DNS entry
62
- def instance_id(address)
81
+ #
82
+ # @param [String] address Host or IP address
83
+ # @return [String] Instance ID, if any
84
+ # @raise [ArgumentError] if instance could not be resolved from address
85
+ def resolve_instance_id(address)
63
86
  logger.debug format("[AWS-SSM] Trying to resolve address %s", address)
64
87
 
65
- ec2 = Aws::EC2::Client.new
66
88
  instances = ec2.describe_instances.reservations.collect { |r| r.instances.first }
67
89
 
68
90
  # Resolve, if DNS name and not Amazon default
@@ -81,75 +103,169 @@ module TrainPlugins
81
103
  ].include?(address)
82
104
  end&.instance_id
83
105
 
84
- raise format("Could not resolve instance ID for address %s", address) if id.nil?
106
+ raise ArgumentError, format("Could not resolve instance ID for address %s", address) if id.nil?
85
107
 
86
108
  logger.debug format("[AWS-SSM] Resolved address %s to instance ID %s", address, id)
109
+
87
110
  id
111
+ rescue ::Aws::Errors::ServiceError => e
112
+ raise ArgumentError, format("Error looking up Instance ID for %s: %s", address, e.message)
113
+ end
114
+
115
+ # Check if this is an IP address
116
+ #
117
+ # @param [String] address Host, IP address or other input
118
+ # @return [Boolean] If it is an IPv4 address
119
+ def ip_address?(address)
120
+ !!(address =~ Resolv::IPv4::Regex)
121
+ end
122
+
123
+ # Check if this is a DNS name
124
+ #
125
+ # @param [String] address Host, IP address or other input
126
+ # @return [Boolean] If it is a DNS name
127
+ def dns_name?(address)
128
+ !ip_address?(address)
129
+ end
130
+
131
+ # Check if this is an internal/external AWS DNS entry
132
+ #
133
+ # @param [String] address Host, IP address or other input
134
+ # @return [Boolean] If it is an Amazon-provided DNS name
135
+ def amazon_dns?(dns)
136
+ dns_name?(dns) && (dns.end_with?(".compute.amazonaws.com") || dns.end_with?(".compute.internal"))
88
137
  end
89
138
 
90
139
  # Request a command invocation and wait until it is registered with an ID
91
- def wait_for_invocation(instance_id, command_id)
92
- invocation_result(instance_id, command_id)
140
+ #
141
+ # @param [String] command_id Command ID from SSM
142
+ def wait_for_invocation(command_id)
143
+ invocation_result(command_id)
93
144
 
94
145
  # Retry until the invocation was created on AWS
95
- rescue Aws::SSM::Errors::InvocationDoesNotExist
96
- sleep @options[:recheck_invocation]
146
+ rescue ::Aws::SSM::Errors::InvocationDoesNotExist
147
+ sleep options[:recheck_invocation]
97
148
  retry
98
149
  end
99
150
 
100
151
  # Return the result of a given command invocation
101
- def invocation_result(instance_id, command_id)
102
- @ssm.get_command_invocation(instance_id: instance_id, command_id: command_id)
152
+ #
153
+ # @param [String] command_id Command ID from SSM
154
+ # @return [Aws::SSM::Types::GetCommandInvocationResult] Invocation result
155
+ def invocation_result(command_id)
156
+ ssm.get_command_invocation(instance_id: instance_id, command_id: command_id)
103
157
  end
104
158
 
105
159
  # Return if a non-terminal command status was given
160
+ #
161
+ # @param [String] name status from invocation
162
+ # @return [Boolean] If execution is still in progress
106
163
  # @see https://docs.aws.amazon.com/systems-manager/latest/userguide/monitor-commands.html
107
164
  def in_progress?(name)
108
165
  %w{Pending InProgress Delayed}.include? name
109
166
  end
110
167
 
111
168
  # Return if a terminal command status was given
169
+ #
170
+ # @param [String] name status from invocation
171
+ # @return [Boolean] If execution is finished, aborted or timed out
112
172
  # @see https://docs.aws.amazon.com/systems-manager/latest/userguide/monitor-commands.html
113
173
  def terminal_state?(name)
114
174
  !in_progress?(name)
115
175
  end
116
176
 
117
177
  # Execute a command via SSM
178
+ #
179
+ # @param [String] address IP, Host or Instance ID
180
+ # @param [String] command Command to execute
181
+ # @return [Aws::SSM::Types::GetCommandInvocationResult] Invocation result
182
+ # @raise [ArgumentError] if instance is not reachable
183
+ # @raise [RuntimeError] if execution failed or timed out
118
184
  def execute_command(address, command)
119
- instance_id = if address.start_with? "i-"
120
- address
121
- else
122
- instance_id(address)
123
- end
185
+ ssm_document = windows_instance? ? "AWS-RunPowerShellScript" : "AWS-RunShellScript"
124
186
 
125
- cmd = @ssm.send_command(instance_ids: [instance_id], document_name: "AWS-RunShellScript", parameters: { "commands": [command] })
187
+ cmd = ssm.send_command(instance_ids: [instance_id], document_name: ssm_document, parameters: { "commands": [command] })
126
188
  cmd_id = cmd.command.command_id
127
189
 
128
- wait_for_invocation(instance_id, cmd_id)
190
+ wait_for_invocation(cmd_id)
129
191
  logger.debug format("[AWS-SSM] Execution ID %s", cmd_id)
130
192
 
131
193
  start_time = Time.now
132
- result = invocation_result(instance_id, cmd.command.command_id)
194
+ result = invocation_result(cmd.command.command_id)
133
195
 
134
- until terminal_state?(result.status) || Time.now - start_time > @options[:execution_timeout]
135
- result = invocation_result(instance_id, cmd.command.command_id)
136
- sleep @options[:recheck_execution]
196
+ until terminal_state?(result.status) || Time.now - start_time > options[:execution_timeout]
197
+ result = invocation_result(cmd.command.command_id)
198
+ sleep options[:recheck_execution]
137
199
  end
138
200
 
139
- if Time.now - start_time > @options[:execution_timeout]
201
+ if Time.now - start_time > options[:execution_timeout]
140
202
  raise format("Timeout waiting for execution")
141
- elsif result.status != "Success"
203
+ elsif !%w{Success Failed}.include? result.status
204
+ # Failing commands is normal for InSpec
142
205
  raise format('Execution failed with state "%s": %s', result.status, result.standard_error_content || "unknown")
143
206
  end
144
207
 
145
208
  result
146
209
  end
147
210
 
148
- # Check if options are as needed
149
- def check_options
150
- unless options[:host]
151
- raise format("Missing required option :host for train-awsssm")
152
- end
211
+ # Check if instance is Windows based.
212
+ # Could also use the `train.connection.platform` mechanics, but they are very slow.
213
+ #
214
+ # @return [Boolean] If this is a Windows instance
215
+ def windows_instance?
216
+ ec2_instance_data.platform == "windows"
217
+ end
218
+
219
+ # Check if instance is running.
220
+ #
221
+ # @param [String] instance_id EC2 instance ID
222
+ # @return [Boolean] If the instance is currently running
223
+ def instance_running?
224
+ ec2_instance_data.state.name == "running"
225
+ end
226
+
227
+ # Check if instance is reachable via SSM.
228
+ #
229
+ # @param [String] instance_id EC2 instance ID
230
+ # @return [Boolean] If the instance is reachable
231
+ def managed_instance?
232
+ instance = ssm_instance_data
233
+ return false unless instance
234
+
235
+ instance.ping_status == "Online"
236
+ end
237
+
238
+ # Get instance data from SSM
239
+ #
240
+ # @param [String] instance_id EC2 instance ID
241
+ # @return [Aws::SSM::Types::InstanceInformation] Available SSM instance data
242
+ # @raise [ArgumentError] if instance ID could not be found
243
+ def ssm_instance_data
244
+ response = ssm.describe_instance_information(filters: [{ key: "InstanceIds", values: [instance_id] }])
245
+
246
+ response.instance_information_list&.first
247
+ rescue ::Aws::Errors::ServiceError => e
248
+ raise ArgumentError, format("Error looking up SSM-managed instance %s: %s", instance_id, e.message)
249
+ end
250
+
251
+ # Get instance data from EC2
252
+ #
253
+ # @param [String] instance_id EC2 instance ID
254
+ # @return [Aws::EC2::Types::Instance] Available instance data
255
+ # @raise [ArgumentError] if instance ID could not be found
256
+ def ec2_instance_data
257
+ instances = ec2.describe_instances(instance_ids: [instance_id])
258
+
259
+ instances.reservations.first.instances.first
260
+ rescue ::Aws::Errors::ServiceError => e
261
+ raise ArgumentError, format("Error looking up Instance %s: %s", instance_id, e.message)
262
+ end
263
+
264
+ # Supported run modes.
265
+ #
266
+ # @return [Array<String>] Supported modes
267
+ def supported_modes
268
+ %w{run-command}
153
269
  end
154
270
  end
155
271
  end
@@ -6,6 +6,7 @@ module TrainPlugins
6
6
  name "awsssm"
7
7
 
8
8
  option :host, required: true
9
+ option :mode, default: "run-command"
9
10
 
10
11
  option :execution_timeout, default: 60.0
11
12
  option :recheck_invocation, default: 1.0
@@ -1,5 +1,5 @@
1
1
  module TrainPlugins
2
2
  module AWSSSM
3
- VERSION = "0.1.1".freeze
3
+ VERSION = "0.2.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: train-awsssm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Heinen