train-awsssm 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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