Skip to content

Commit e80670d

Browse files
authored
Sync ManageAgent test document: add set-env action (#727)
1 parent 8528bfc commit e80670d

8 files changed

Lines changed: 517 additions & 265 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/aws/aws-sdk-go-v2/service/sts v1.26.2
2626
github.com/aws/aws-sdk-go-v2/service/xray v1.23.2
2727
github.com/aws/aws-xray-sdk-go v1.8.3
28+
github.com/aws/smithy-go v1.18.1
2829
github.com/cenkalti/backoff/v4 v4.2.1
2930
github.com/google/uuid v1.4.0
3031
github.com/mitchellh/mapstructure v1.5.0
@@ -64,7 +65,6 @@ require (
6465
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.8 // indirect
6566
github.com/aws/aws-sdk-go-v2/service/sso v1.18.2 // indirect
6667
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.2 // indirect
67-
github.com/aws/smithy-go v1.18.1 // indirect
6868
github.com/davecgh/go-spew v1.1.1 // indirect
6969
github.com/go-logr/logr v1.3.0 // indirect
7070
github.com/go-logr/stdr v1.2.2 // indirect

test/ssm_document/constants.go

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,59 @@ const (
77
testManageAgentDocument = "Test-AmazonCloudWatch-ManageAgent-"
88

99
// Actions
10-
actionStart = "start"
11-
actionStop = "stop"
12-
actionConfigure = "configure"
13-
actionConfigureAppend = "configure (append)"
14-
actionConfigureRemove = "configure (remove)"
10+
actionStart = "start"
11+
actionStop = "stop"
12+
actionConfigure = "configure"
13+
actionConfigureAppend = "configure (append)"
14+
actionConfigureRemove = "configure (remove)"
15+
actionSetEnv = "set-env"
16+
actionSetEnvMerge = "set-env (merge)"
17+
actionSetEnvOverwrite = "set-env (overwrite)"
18+
actionSetEnvEmpty = "set-env (empty)"
19+
actionSetEnvInvalidPattern = "set-env (invalid pattern)"
20+
21+
// actionSetEnvInvalidBacktick exercises rejection of a value containing a backtick.
22+
actionSetEnvInvalidBacktick = "set-env (invalid backtick)"
23+
24+
// actionSetEnvInvalidKey exercises rejection of a malformed key starting with a digit.
25+
actionSetEnvInvalidKey = "set-env (invalid key)"
1526

1627
// Parameters
1728
paramAction = "action"
1829
paramOptionalConfigurationSource = "optionalConfigurationSource"
1930
paramOptionalConfigurationLocation = "optionalConfigurationLocation"
2031
paramOptionalRestart = "optionalRestart"
32+
paramOptionalEnvironmentVariable = "optionalEnvironmentVariable"
33+
34+
// set-env test environment variables. Custom (non translator-managed) key names so
35+
// configure actions and agent restarts do not overwrite them. Values include spaces
36+
// to exercise quoting through the document -> ctl -> agent binary chain.
37+
setEnvKey1 = "CWA_TEST_VAR_ONE"
38+
setEnvValue1 = "value with spaces"
39+
setEnvKey2 = "CWA_TEST_VAR_TWO"
40+
setEnvValue2 = "another value"
41+
42+
// setEnvOverwriteValue is used to overwrite setEnvKey1 with a new value.
43+
setEnvOverwriteValue = "overwritten value"
44+
45+
// setEnvInvalidPatternValue contains a '$' which violates the document's
46+
// optionalEnvironmentVariable allowedPattern and is rejected by SSM at SendCommand.
47+
setEnvInvalidPatternValue = "CWA_TEST_INVALID=$not_allowed"
48+
49+
// setEnvInvalidBacktickValue contains a backtick which violates the document's
50+
// optionalEnvironmentVariable allowedPattern and is rejected by SSM at SendCommand.
51+
setEnvInvalidBacktickValue = "CWA_TEST_BACKTICK=val`ue"
52+
53+
// setEnvInvalidKeyValue has a malformed key starting with a digit, violating the
54+
// allowedPattern's key portion ([A-Za-z_][A-Za-z0-9_]*=...) and rejected at SendCommand.
55+
setEnvInvalidKeyValue = "1FOO=bar"
56+
57+
// setEnvOutputPrefix is printed by the ctl on a successful set-env ("Set <KEY>").
58+
setEnvOutputPrefix = "Set "
59+
60+
// setEnvEmptyErrorMessage is printed by the SSM document when optionalEnvironmentVariable
61+
// is empty for the set-env action.
62+
setEnvEmptyErrorMessage = "optionalEnvironmentVariable must be specified for the set-env action"
2163

2264
// Parameter Values
2365
configSourceSSM = "ssm"
@@ -45,7 +87,10 @@ type agentStatus struct {
4587
}
4688

4789
type testCase struct {
48-
parameters map[string][]string
90+
parameters map[string][]string
91+
// actionName is a human-readable test label used only in log messages and error
92+
// output. It is NOT the SSM action parameter value (which is carried in
93+
// parameters[paramAction]).
4994
actionName string
5095
expectedAgentStatus string
5196
expectedConfigStatus string

test/ssm_document/helper.go

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ package ssm_document
55

66
import (
77
"encoding/json"
8+
"errors"
89
"fmt"
910
"log"
11+
"os"
12+
"strings"
1013

1114
"github.com/aws/aws-sdk-go-v2/service/ssm"
1215
"github.com/aws/aws-sdk-go-v2/service/ssm/types"
16+
"github.com/aws/smithy-go"
1317

1418
"github.com/aws/amazon-cloudwatch-agent-test/util/awsservice"
1519
)
@@ -24,23 +28,23 @@ func cleanupSSMParameter(name string) {
2428
}
2529
}
2630

27-
func RunAndVerifySSMAction(documentName string, instanceIds []string, tc testCase) error {
31+
func runAndVerifySSMAction(documentName string, instanceIds []string, tc testCase) error {
2832
log.Printf("Testing %s action", tc.actionName)
2933

3034
out, err := awsservice.RunSSMDocument(documentName, instanceIds, tc.parameters)
3135
if err != nil {
3236
return fmt.Errorf("%s action failed: %v", tc.actionName, err)
3337
}
3438

35-
if err := VerifyAgentAction(out, instanceIds[0], documentName, tc); err != nil {
39+
if err := verifyAgentAction(out, instanceIds[0], documentName, tc); err != nil {
3640
return fmt.Errorf("%s verification failed: %v", tc.actionName, err)
3741
}
3842

3943
log.Printf("%s action completed successfully", tc.actionName)
4044
return nil
4145
}
4246

43-
func VerifyAgentAction(out *ssm.SendCommandOutput, instanceId, documentName string, tc testCase) error {
47+
func verifyAgentAction(out *ssm.SendCommandOutput, instanceId, documentName string, tc testCase) error {
4448
var status agentStatus
4549

4650
//Wait for command completion
@@ -63,6 +67,10 @@ func VerifyAgentAction(out *ssm.SendCommandOutput, instanceId, documentName stri
6367
return fmt.Errorf("failed to get status result: %v", err)
6468
}
6569

70+
if len(statusResult.CommandInvocations) == 0 {
71+
return fmt.Errorf("no command invocations returned for status check")
72+
}
73+
6674
for _, plugin := range statusResult.CommandInvocations[0].CommandPlugins {
6775
if plugin.Status == types.CommandPluginStatusFailed {
6876
return fmt.Errorf("command plugin failed: %s", *plugin.Name)
@@ -88,3 +96,127 @@ func VerifyAgentAction(out *ssm.SendCommandOutput, instanceId, documentName stri
8896

8997
return nil
9098
}
99+
100+
// runAndVerifySSMActionWithOutput behaves like runAndVerifySSMAction and additionally
101+
// asserts that expectedOutput appears in the command's output.
102+
func runAndVerifySSMActionWithOutput(documentName string, instanceIds []string, tc testCase, expectedOutput string) error {
103+
log.Printf("Testing %s action", tc.actionName)
104+
105+
out, err := awsservice.RunSSMDocument(documentName, instanceIds, tc.parameters)
106+
if err != nil {
107+
return fmt.Errorf("%s action failed: %v", tc.actionName, err)
108+
}
109+
110+
result, err := awsservice.WaitForCommandCompletion(*out.Command.CommandId, instanceIds[0])
111+
if err != nil {
112+
commandOutput := awsservice.GetCommandInvocationDetails(*out.Command.CommandId, instanceIds[0])
113+
return fmt.Errorf("%s action failed to complete: %v\nCommand output:\n%s", tc.actionName, err, commandOutput)
114+
}
115+
116+
if !commandOutputContains(result, expectedOutput) {
117+
commandOutput := awsservice.GetCommandInvocationDetails(*out.Command.CommandId, instanceIds[0])
118+
return fmt.Errorf("%s output verification failed: expected output %q not found\nCommand output:\n%s", tc.actionName, expectedOutput, commandOutput)
119+
}
120+
121+
if err := verifyAgentAction(out, instanceIds[0], documentName, tc); err != nil {
122+
return fmt.Errorf("%s verification failed: %v", tc.actionName, err)
123+
}
124+
125+
log.Printf("%s action completed successfully", tc.actionName)
126+
return nil
127+
}
128+
129+
// runAndVerifySSMActionFailure runs the document action and expects the command invocation
130+
// to reach the terminal Failed state (e.g. document-level parameter validation errors).
131+
// If expectedOutput is non-empty, it must appear in the failed command's output.
132+
func runAndVerifySSMActionFailure(documentName string, instanceIds []string, tc testCase, expectedOutput string) error {
133+
log.Printf("Testing %s action (expecting failure)", tc.actionName)
134+
135+
out, err := awsservice.RunSSMDocument(documentName, instanceIds, tc.parameters)
136+
if err != nil {
137+
return fmt.Errorf("%s action failed to send: %v", tc.actionName, err)
138+
}
139+
140+
commandId := *out.Command.CommandId
141+
_, err = awsservice.WaitForCommandCompletion(commandId, instanceIds[0])
142+
commandOutput := awsservice.GetCommandInvocationDetails(commandId, instanceIds[0])
143+
if err == nil {
144+
return fmt.Errorf("%s action was expected to fail but succeeded\nCommand output:\n%s", tc.actionName, commandOutput)
145+
}
146+
// WaitForCommandCompletion returns a *CommandTerminalError for Failed/Cancelled/TimedOut;
147+
// require specifically the Failed status.
148+
var termErr *awsservice.CommandTerminalError
149+
if !errors.As(err, &termErr) || termErr.Status != types.CommandInvocationStatusFailed {
150+
return fmt.Errorf("%s action reached an unexpected terminal state: %v\nCommand output:\n%s", tc.actionName, err, commandOutput)
151+
}
152+
if expectedOutput != "" && !strings.Contains(commandOutput, expectedOutput) {
153+
return fmt.Errorf("%s failure output verification failed: expected output %q not found\nCommand output:\n%s", tc.actionName, expectedOutput, commandOutput)
154+
}
155+
156+
log.Printf("%s action failed as expected", tc.actionName)
157+
return nil
158+
}
159+
160+
// verifyEnvConfigContent reads the agent's env-config.json directly from the local
161+
// filesystem (the test runs on the instance) and asserts that the file contains every
162+
// expected key/value pair. Additional keys in the file are ignored.
163+
func verifyEnvConfigContent(expected map[string]string) error {
164+
log.Printf("Verifying env-config.json content via direct file read: %s", envConfigPath)
165+
166+
data, err := os.ReadFile(envConfigPath)
167+
if err != nil {
168+
return fmt.Errorf("failed to read env-config.json at %s: %v", envConfigPath, err)
169+
}
170+
171+
var envConfig map[string]string
172+
if err := json.Unmarshal(data, &envConfig); err != nil {
173+
return fmt.Errorf("failed to unmarshal env-config.json: %v\nContent:\n%s", err, string(data))
174+
}
175+
176+
for key, want := range expected {
177+
got, ok := envConfig[key]
178+
if !ok {
179+
return fmt.Errorf("env-config.json is missing expected key %q. Content: %v", key, envConfig)
180+
}
181+
if got != want {
182+
return fmt.Errorf("env-config.json key %q verification failed. Expected: %q, Got: %q", key, want, got)
183+
}
184+
}
185+
186+
log.Println("env-config.json content verified successfully")
187+
return nil
188+
}
189+
190+
// commandOutputContains reports whether any command plugin's output contains expected.
191+
func commandOutputContains(result *ssm.ListCommandInvocationsOutput, expected string) bool {
192+
if len(result.CommandInvocations) == 0 {
193+
return false
194+
}
195+
for _, plugin := range result.CommandInvocations[0].CommandPlugins {
196+
if plugin.Output != nil && strings.Contains(*plugin.Output, expected) {
197+
return true
198+
}
199+
}
200+
return false
201+
}
202+
203+
// verifySSMSendCommandRejection runs the document action and expects SendCommand itself
204+
// to reject the request (e.g. because a parameter value violates an allowedPattern).
205+
// The AWS SDK returns an InvalidParameters error from SendCommand in this case.
206+
func verifySSMSendCommandRejection(documentName string, instanceIds []string, tc testCase) error {
207+
log.Printf("Testing %s action (expecting SendCommand rejection)", tc.actionName)
208+
209+
_, err := awsservice.RunSSMDocument(documentName, instanceIds, tc.parameters)
210+
if err == nil {
211+
return fmt.Errorf("%s action was expected to be rejected at SendCommand but succeeded", tc.actionName)
212+
}
213+
214+
// SSM returns an InvalidParameters API error when allowedPattern validation fails.
215+
var apiErr smithy.APIError
216+
if !errors.As(err, &apiErr) || apiErr.ErrorCode() != "InvalidParameters" {
217+
return fmt.Errorf("%s action failed with unexpected error (expected InvalidParameters API error): %v", tc.actionName, err)
218+
}
219+
220+
log.Printf("%s action rejected at SendCommand as expected: %v", tc.actionName, err)
221+
return nil
222+
}

test/ssm_document/resources/test_amazoncloudwatch_manageagent.json

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"start",
1414
"status",
1515
"status-with-workloads",
16-
"stop"
16+
"stop",
17+
"set-env"
1718
],
1819
"interpolationType": "ENV_VAR"
1920
},
@@ -55,6 +56,13 @@
5556
"no"
5657
],
5758
"interpolationType": "ENV_VAR"
59+
},
60+
"optionalEnvironmentVariable": {
61+
"description": "Only for the 'set-env' action. Environment variable to persist for the agent process, in KEY=VALUE format. Requires an agent version whose ctl supports the set-env action. The value may not contain double quotes, backticks, dollar signs, backslashes, or control characters.",
62+
"type": "String",
63+
"default": "",
64+
"allowedPattern": "^([A-Za-z_][A-Za-z0-9_]*=[^\"`$\\\\\\x00-\\x1f\\x7f]*)?$",
65+
"interpolationType": "ENV_VAR"
5866
}
5967
},
6068
"mainSteps": [
@@ -74,6 +82,7 @@
7482
" if (-not (Test-Path env:SSM_optionalConfigurationSource)) { $env:SSM_optionalConfigurationSource = \"{{optionalConfigurationSource}}\" }",
7583
" if (-not (Test-Path env:SSM_optionalConfigurationLocation)) { $env:SSM_optionalConfigurationLocation = \"{{optionalConfigurationLocation}}\" }",
7684
" if (-not (Test-Path env:SSM_optionalRestart)) { $env:SSM_optionalRestart = \"{{optionalRestart}}\" }",
85+
" if (-not (Test-Path env:SSM_optionalEnvironmentVariable)) { $env:SSM_optionalEnvironmentVariable = \"{{optionalEnvironmentVariable}}\" }",
7786
" Set-StrictMode -Version 2.0",
7887
" $ErrorActionPreference = 'Stop'",
7988
" $Cmd = \"${Env:ProgramFiles}\\Amazon\\AmazonCloudWatchAgent\\amazon-cloudwatch-agent-ctl.ps1\"",
@@ -83,6 +92,14 @@
8392
" }",
8493
" $Params = @()",
8594
" $Action = $env:SSM_action",
95+
" if ($Action -eq 'set-env') {",
96+
" if (!$env:SSM_optionalEnvironmentVariable) {",
97+
" Write-Output 'optionalEnvironmentVariable must be specified for the set-env action'",
98+
" exit 1",
99+
" }",
100+
" & \"$Cmd\" -a set-env -e \"$env:SSM_optionalEnvironmentVariable\"",
101+
" exit $LASTEXITCODE",
102+
" }",
86103
" # Handle multi-config merge for configure action",
87104
" $ConfigLocation = $env:SSM_optionalConfigurationLocation",
88105
" if ($Action -eq 'configure' -and $env:SSM_optionalConfigurationSource -eq 'ssm' -and $ConfigLocation -match ',') {",
@@ -173,6 +190,7 @@
173190
" if [ -z \"${SSM_optionalConfigurationSource}\" ]; then export SSM_optionalConfigurationSource=\"{{optionalConfigurationSource}}\"; fi",
174191
" if [ -z \"${SSM_optionalConfigurationLocation}\" ]; then export SSM_optionalConfigurationLocation=\"{{optionalConfigurationLocation}}\"; fi",
175192
" if [ -z \"${SSM_optionalRestart}\" ]; then export SSM_optionalRestart=\"{{optionalRestart}}\"; fi",
193+
" if [ -z \"${SSM_optionalEnvironmentVariable}\" ]; then export SSM_optionalEnvironmentVariable=\"{{optionalEnvironmentVariable}}\"; fi",
176194
" set -e",
177195
" set -u",
178196
" cmd='/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl'",
@@ -181,6 +199,14 @@
181199
" exit 1",
182200
" fi",
183201
" action=\"${SSM_action}\"",
202+
" if [ \"${action}\" = \"set-env\" ]; then",
203+
" if [ -z \"${SSM_optionalEnvironmentVariable}\" ]; then",
204+
" echo 'optionalEnvironmentVariable must be specified for the set-env action'",
205+
" exit 1",
206+
" fi",
207+
" \"${cmd}\" -a set-env -e \"${SSM_optionalEnvironmentVariable}\"",
208+
" exit $?",
209+
" fi",
184210
" # Handle multi-config merge for configure action",
185211
" configlocation=\"${SSM_optionalConfigurationLocation}\"",
186212
" if [ \"${action}\" = \"configure\" ] && [ \"${SSM_optionalConfigurationSource}\" = \"ssm\" ] && echo \"${configlocation}\" | grep -q \",\"; then",
@@ -266,6 +292,7 @@
266292
" if [ -z \"${SSM_optionalConfigurationSource}\" ]; then export SSM_optionalConfigurationSource=\"{{optionalConfigurationSource}}\"; fi",
267293
" if [ -z \"${SSM_optionalConfigurationLocation}\" ]; then export SSM_optionalConfigurationLocation=\"{{optionalConfigurationLocation}}\"; fi",
268294
" if [ -z \"${SSM_optionalRestart}\" ]; then export SSM_optionalRestart=\"{{optionalRestart}}\"; fi",
295+
" if [ -z \"${SSM_optionalEnvironmentVariable}\" ]; then export SSM_optionalEnvironmentVariable=\"{{optionalEnvironmentVariable}}\"; fi",
269296
" set -e",
270297
" set -u",
271298
" cmd='/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl'",
@@ -274,6 +301,14 @@
274301
" exit 1",
275302
" fi",
276303
" action=\"${SSM_action}\"",
304+
" if [ \"${action}\" = \"set-env\" ]; then",
305+
" if [ -z \"${SSM_optionalEnvironmentVariable}\" ]; then",
306+
" echo 'optionalEnvironmentVariable must be specified for the set-env action'",
307+
" exit 1",
308+
" fi",
309+
" \"${cmd}\" -a set-env -e \"${SSM_optionalEnvironmentVariable}\"",
310+
" exit $?",
311+
" fi",
277312
" # Handle multi-config merge for configure action",
278313
" configlocation=\"${SSM_optionalConfigurationLocation}\"",
279314
" if [ \"${action}\" = \"configure\" ] && [ \"${SSM_optionalConfigurationSource}\" = \"ssm\" ] && echo \"${configlocation}\" | grep -q \",\"; then",
@@ -345,4 +380,4 @@
345380
}
346381
}
347382
]
348-
}
383+
}

0 commit comments

Comments
 (0)