Post

AWS SSM - Send commands to run on remote Windows EC2 Instance

Aim

Recently, I wanted to execute commands from my local machine (even a CI machine) on a remote Windows EC2 instance. In other words, send a bunch of commands that get executed on a remote EC2 instance. And get back the status of the execution and the its output. This relies on the RunCommand feature of AWS Session Manager.

Prerequisites

A few prerequisites are:

  • EC2 instance is running windows;
  • The instance profile has role that allows connecting to SSM like arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
  • aws cli installed and configured locally. I set mine to use a specific profiles. If you don’t use profiles, then leave off the profile part of the command.

Workflow

I use the aws ssm send-command to execute a command on the EC2 instance. This queues the command to run on the instance. To know the if the command was executed and what the output was, I run the aws ssm get-command-invocation

What’s the spec for that document?

What the document is, what it allows us to send is its spec or API. Since the target of my commands is a Windows instance, I’ll use PowerShell.

To see its spec, run aws ssm describe-document --name "AWS-RunPowerShellScript". The json output is below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
    "Document": {
        "Hash": "2142e42a19e0955cc09e43600bf2e633df1917b69d2be9693737dfd62e0fdf61",
        "HashType": "Sha256",
        "Name": "AWS-RunPowerShellScript",
        "Owner": "Amazon",
        "CreatedDate": "2017-08-30T23:34:35.400000+02:00",
        "Status": "Active",
        "DocumentVersion": "1",
        "Description": "Run a PowerShell script or specify the paths to scripts to run.",
        "Parameters": [
            {
                "Name": "commands",
                "Type": "StringList",
                "Description": "(Required) Specify the commands to run or the paths to existing scripts on the instance."
            },
            {
                "Name": "workingDirectory",
                "Type": "String",
                "Description": "(Optional) The path to the working directory on your instance.",
                "DefaultValue": ""
            },
            {
                "Name": "executionTimeout",
                "Type": "String",
                "Description": "(Optional) The time in seconds for a command to be completed before it is considered to have failed. Default is 3600 (1 hour). Maximum is 172800 (48 hours).",
                "DefaultValue": "3600"
            }
        ],
        "PlatformTypes": [
            "Windows",
            "Linux"
        ],
        "DocumentType": "Command",
        "SchemaVersion": "1.2",
        "LatestVersion": "1",
        "DefaultVersion": "1",
        "DocumentFormat": "JSON",
        "Tags": []
    }
}

From the parameters block, we see that we need to send commands. The other 2 (workingDirectory, executionTimeout) are useful but not important for this TIL.

For simple commands, string might be enough

For a simple command like print all environment variables (dir env:), a string might be enough in the commands parameter.

It would look like this snippet below (see line 4):

1
2
3
4
5
6
aws ssm send-command \
--instance-ids "i-06c8b03dd27afb8af" \
--document-name "AWS-RunPowerShellScript" \
--parameters commands="dir env:" \
--output json \
--profile dev-a

From the output, we’re interested in the CommandId (line 3) and the Status (line 18). The output looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
    "Command": {
        "CommandId": "44dd9ec3-a7ea-469d-99ca-441352aa7b97",
        "DocumentName": "AWS-RunPowerShellScript",
        "DocumentVersion": "$DEFAULT",
        "Comment": "",
        "ExpiresAfter": "2024-03-01T20:48:08.161000+01:00",
        "Parameters": {
            "commands": [
                "dir env:"
            ]
        },
        "InstanceIds": [
            "i-06c8b03dd27afb8af"
        ],
        "Targets": [],
        "RequestedDateTime": "2024-03-01T20:48:08.161000+01:00",
        "Status": "Pending",
        "StatusDetails": "Pending",
        "OutputS3Region": "us-east-1",
        "OutputS3BucketName": "",
        "OutputS3KeyPrefix": "",
        "MaxConcurrency": "50",
        "MaxErrors": "0",
        "TargetCount": 1,
        "CompletedCount": 0,
        "ErrorCount": 0,
        "DeliveryTimedOutCount": 0,
        "ServiceRole": "",
        "NotificationConfig": {
            "NotificationArn": "",
            "NotificationEvents": [],
            "NotificationType": ""
        },
        "CloudWatchOutputConfig": {
            "CloudWatchLogGroupName": "",
            "CloudWatchOutputEnabled": false
        }
    }
}

If I’m in a CI environment, then I’m only interested in the CommandId because the subsequent calls use the CommandId as the query string.

1
2
3
4
5
6
7
8
9
10
11
12
command_id=$( \
	aws ssm send-command \
	--instance-ids "i-06c8b03dd27afb8af" \
	--document-name "AWS-RunPowerShellScript" \
	--parameters commands="dir env:" \
	--query "Command.CommandId"  \
	--output text  \
	--profile ci
)
echo "CommandId is $command_id"

# output is CommandId is 44dd9ec3-a7ea-469d-99ca-441352aa7b97

For more complex commands, prefer an array or json in the command parameter

If I have a more complex command or one that has special characters, then using a json object (like this stackoverflow answer) in the commands parameter is preferable to using a string.

Say, you’d like to see the host and domain that an EC2 instance is joined to. Executing [System.Net.Dns]::GetHostByName($env:computerName) on the instance itself returns a decent output.

Executing the same

1
2
3
4
5
6
aws ssm send-command \
--instance-ids "i-06c8b03dd27afb8af" \
--document-name "AWS-RunPowerShellScript" \
--parameters commands="[System.Net.Dns]::GetHostByName($env:computerName)" \
--output json \
--profile dev-a

returns this error

1
2
Error parsing parameter '--parameters': Expected: ',', received: ':' for input:
commands=[System.Net.Dns]::GetHostByName(:computerName)

Switching to array-style would look like:

1
2
3
4
5
6
aws ssm send-command \
--instance-ids "i-06c8b03dd27afb8af" \
--document-name "AWS-RunPowerShellScript" \
--parameters commands='["[System.Net.Dns]::GetHostByName","$env:computerName"]' \
--output json \
--profile dev-a

Status and output of the command

Running the aws ssm get-command-invocation below, gives

1
2
3
4
5
aws ssm get-command-invocation \
--command-id "5f7d72c7-9cf4-4a4d-a7ed-fefd06d22f8c" \
--instance-id "i-06c8b03dd27afb8af" \
--output json \
--profile dev

The output looks like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
    "CommandId": "5f7d72c7-9cf4-4a4d-a7ed-fefd06d22f8c",
    "InstanceId": "i-06c8b03dd27afb8af",
    "Comment": "",
    "DocumentName": "AWS-RunPowerShellScript",
    "DocumentVersion": "$DEFAULT",
    "PluginName": "aws:runPowerShellScript",
    "ResponseCode": 0,
    "ExecutionStartDateTime": "2024-03-01T20:08:56.792Z",
    "ExecutionElapsedTime": "PT2.44S",
    "ExecutionEndDateTime": "2024-03-01T20:08:58.792Z",
    "Status": "Success",
    "StatusDetails": "Success",
    "StandardOutputContent": "\r\nOverloadDefinitions                                           \r\n-------------------                                           \r\nstatic System.Net.IPHostEntry GetHostByName(string hostName)  \r\n                                                              \r\nMVP-A12345F\r\n\r\n\r\n",
    "StandardOutputUrl": "",
    "StandardErrorContent": "",
    "StandardErrorUrl": "",
    "CloudWatchOutputConfig": {
        "CloudWatchLogGroupName": "",
        "CloudWatchOutputEnabled": false
    }
}

In CI machines, I’d run

1
2
3
4
5
6
7
aws ssm send-command \
--instance-ids "i-06c8b03dd27afb8af" \
--document-name "AWS-RunPowerShellScript" \
--parameters commands='["[System.Net.Dns]::GetHostByName","$env:computerName"]' \
--query "Command.CommandId"  \
--output text \
--profile ci

Initial attempt that didn’t work

Because I started this on a Linux/Mac machine, I thought using AWS-RunShellScript would be sufficient. But that failed with the error An error occurred (UnsupportedPlatformType) when calling the SendCommand operation: Cannot perform operation for instance id i-xxx of platform type Windows.

Running the aws ssm describe-document --name "AWS-RunShellScript" gives the output below, where the PlatformTypes excludes Windows. Duh!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
    "Document": {
        "Hash": "99749de5e62f71e5ebe9a55c2321e2c394796afe7208cff048696541e6f6771e",
        "HashType": "Sha256",
        "Name": "AWS-RunShellScript",
        "Owner": "Amazon",
        "CreatedDate": "2017-08-30T23:33:44.432000+02:00",
        "Status": "Active",
        "DocumentVersion": "1",
        "Description": "Run a shell script or specify the commands to run.",
        "Parameters": [
            {
                "Name": "commands",
                "Type": "StringList",
                "Description": "(Required) Specify a shell script or a command to run."
            },
            {
                "Name": "workingDirectory",
                "Type": "String",
                "Description": "(Optional) The path to the working directory on your instance.",
                "DefaultValue": ""
            },
            {
                "Name": "executionTimeout",
                "Type": "String",
                "Description": "(Optional) The time in seconds for a command to complete before it is considered to have failed. Default is 3600 (1 hour). Maximum is 172800 (48 hours).",
                "DefaultValue": "3600"
            }
        ],
        "PlatformTypes": [
            "Linux",
            "MacOS"
        ],
        "DocumentType": "Command",
        "SchemaVersion": "1.2",
        "LatestVersion": "1",
        "DefaultVersion": "1",
        "DocumentFormat": "JSON",
        "Tags": []
    }
}
This post is licensed under CC BY 4.0 by the author.