55End-to-end S3 API smoke test for Infinia, driven via AWS SSM.
66
77Flow:
8- 1) Find the realm EC2 by tag (e.g., Role=realm). Get its instance ID and public IP.
9- 2) On the realm instance (via SSM), run redcli to create s3_test_user and mint S3 access keys.
10- 3) Parse S3_KEY and S3_SECRET from the SSM output.
8+ 1) Find the realm EC2 by tag (e.g., Role=realm). Get instance ID + IP.
9+ 2) On the realm (via SSM), run redcli to mint S3 access keys for s3_test_user .
10+ 3) Parse S3_KEY / S3_SECRET from the output (no secrets are printed to runner logs) .
11114) Find all client EC2 instances by tag (e.g., Role=client).
12- 5) On each client (via SSM), write ~/.aws/credentials with the generated keys, then:
13- - create a unique bucket (ignore “already exists”),
14- - upload a timestamped file via aws s3 cp to the Infinia endpoint,
15- - list bucket objects and verify the file appears.
16-
17- Exit codes:
18- 0 on success; non-zero on failure.
19-
20- Requirements on the runner:
21- - AWS credentials set (GitHub action `configure-aws-credentials` is fine)
22- - boto3, botocore installed (your pipeline already installs boto3)
23-
24- Assumptions:
25- - Clients already have AWS CLI v2 installed (as per your note).
26- - redcli is installed on the realm instance.
27- - The S3 endpoint is https://<realm-public-ip>:<endpoint_port>.
12+ 5) On each client (via SSM), write ~/.aws/credentials with the generated keys,
13+ create a unique bucket, upload a test file, list objects, and confirm success.
14+
15+ Robustness:
16+ - Uses public IP if available, else falls back to private IP automatically.
17+ - You can force private IP with --use-private-endpoint.
18+ - You can override the endpoint host with --endpoint-host (e.g. internal NLB DNS).
19+ - Optional --no-verify-ssl to skip TLS verification against the endpoint.
20+
21+ Exit codes: 0 = success; non-zero = failure.
2822"""
2923
3024import argparse
31- import os
25+ import datetime
26+ import random
3227import re
3328import sys
3429import time
35- import json
36- import random
37- import datetime
30+ from typing import Optional , Dict , List
3831
3932import boto3
40- from botocore .exceptions import ClientError
4133
4234
4335def eprint (* args , ** kwargs ):
4436 print (* args , file = sys .stderr , ** kwargs )
4537
4638
47- def find_one_instance_id_by_tag (ec2 , key , value ):
39+ # ----------------------------- EC2 helpers ----------------------------- #
40+
41+ def find_one_instance_id_by_tag (ec2 , key : str , value : str ) -> Optional [str ]:
4842 resp = ec2 .describe_instances (
4943 Filters = [
5044 {"Name" : f"tag:{ key } " , "Values" : [value ]},
5145 {"Name" : "instance-state-name" , "Values" : ["running" ]},
5246 ],
53- MaxResults = 50 ,
47+ MaxResults = 100 ,
5448 )
5549 for r in resp .get ("Reservations" , []):
5650 for i in r .get ("Instances" , []):
5751 return i ["InstanceId" ]
5852 return None
5953
6054
61- def get_public_ip_by_instance_id (ec2 , instance_id ) :
55+ def get_public_ip_by_instance_id (ec2 , instance_id : str ) -> Optional [ str ] :
6256 resp = ec2 .describe_instances (InstanceIds = [instance_id ])
6357 inst = resp ["Reservations" ][0 ]["Instances" ][0 ]
6458 return inst .get ("PublicIpAddress" )
6559
6660
67- def list_instance_ids_by_tag (ec2 , key , value ):
68- ids = []
61+ def get_private_ip_by_instance_id (ec2 , instance_id : str ) -> Optional [str ]:
62+ resp = ec2 .describe_instances (InstanceIds = [instance_id ])
63+ inst = resp ["Reservations" ][0 ]["Instances" ][0 ]
64+ return inst .get ("PrivateIpAddress" )
65+
66+
67+ def list_instance_ids_by_tag (ec2 , key : str , value : str ) -> List [str ]:
68+ ids : List [str ] = []
6969 paginator = ec2 .get_paginator ("describe_instances" )
7070 for page in paginator .paginate (
7171 Filters = [
@@ -79,10 +79,14 @@ def list_instance_ids_by_tag(ec2, key, value):
7979 return ids
8080
8181
82- def ssm_send_and_wait (ssm , instance_ids , commands , comment , timeout_sec = 600 , poll_sec = 5 ):
83- """Send AWS-RunShellScript to one or many instances and wait for completion.
82+ # ----------------------------- SSM helpers ----------------------------- #
8483
85- Returns dict: {instance_id: {"Status": str, "StdOut": str, "StdErr": str}}
84+ def ssm_send_and_wait (ssm , instance_ids , commands , comment , timeout_sec = 900 , poll_sec = 5 ) -> Dict [str , Dict [str , str ]]:
85+ """
86+ Send AWS-RunShellScript to one or many instances and wait for completion.
87+
88+ Returns dict keyed by instance_id:
89+ {instance_id: {"Status": str, "StdOut": str, "StdErr": str}}
8690 """
8791 if isinstance (instance_ids , str ):
8892 instance_ids = [instance_ids ]
@@ -96,7 +100,7 @@ def ssm_send_and_wait(ssm, instance_ids, commands, comment, timeout_sec=600, pol
96100 cmd_id = resp ["Command" ]["CommandId" ]
97101
98102 deadline = time .time () + timeout_sec
99- results = {iid : None for iid in instance_ids }
103+ results : Dict [ str , Optional [ Dict [ str , str ]]] = {iid : None for iid in instance_ids }
100104
101105 while time .time () < deadline :
102106 done = True
@@ -123,15 +127,17 @@ def ssm_send_and_wait(ssm, instance_ids, commands, comment, timeout_sec=600, pol
123127 break
124128 time .sleep (poll_sec )
125129
126- # Fill any missing as timeout
130+ # Mark any missing as timeout
127131 for iid in instance_ids :
128132 if results [iid ] is None :
129133 results [iid ] = {"Status" : "TimedOut" , "StdOut" : "" , "StdErr" : "" }
130134
131- return results
135+ return results # type: ignore[return-value]
136+
132137
138+ # ----------------------------- Parsing helpers ----------------------------- #
133139
134- def parse_redcli_creds_from_text (text ):
140+ def parse_redcli_creds_from_text (text : str ):
135141 """
136142 Extract S3_KEY and S3_SECRET from redcli pretty-table-like output.
137143
@@ -146,49 +152,76 @@ def parse_redcli_creds_from_text(text):
146152 return s3_key .strip (), s3_secret .strip ()
147153
148154
149- def main ():
155+ # ----------------------------- Main ----------------------------- #
156+
157+ def main () -> int :
150158 ap = argparse .ArgumentParser (description = "Infinia S3 API smoke test via SSM" )
151159 ap .add_argument ("--region" , required = True , help = "AWS region (e.g. us-east-1)" )
152160 ap .add_argument ("--admin-password" , required = True , help = "Infinia realm_admin password" )
153161 ap .add_argument ("--realm-tag-key" , default = "Role" )
154162 ap .add_argument ("--realm-tag-value" , default = "realm" )
155163 ap .add_argument ("--client-tag-key" , default = "Role" )
156164 ap .add_argument ("--client-tag-value" , default = "client" )
157- ap .add_argument ("--endpoint-port" , type = int , default = 8111 )
158- ap .add_argument ("--no-verify-ssl" , action = "store_true" , default = True ,
159- help = "Use --no-verify-ssl against the endpoint" )
160- ap .add_argument ("--timeout-sec" , type = int , default = 900 )
165+
166+ ap .add_argument ("--endpoint-port" , type = int , default = 8111 , help = "Infinia S3 endpoint port (default: 8111)" )
167+ ap .add_argument ("--use-private-endpoint" , action = "store_true" ,
168+ help = "Force using the realm's private IP for the endpoint URL" )
169+ ap .add_argument ("--endpoint-host" , default = "" ,
170+ help = "Override endpoint host entirely (e.g., internal NLB DNS). If set, IP detection is skipped." )
171+ ap .add_argument ("--no-verify-ssl" , action = "store_true" ,
172+ help = "Pass --no-verify-ssl to aws CLI when calling the endpoint" )
173+
174+ ap .add_argument ("--timeout-sec" , type = int , default = 900 , help = "Overall SSM per-command timeout (default: 900s)" )
175+
161176 args = ap .parse_args ()
162177
163178 session = boto3 .Session (region_name = args .region )
164179 ec2 = session .client ("ec2" )
165180 ssm = session .client ("ssm" )
166181
167- # 1) Realm
182+ # 1) Realm instance
168183 realm_id = find_one_instance_id_by_tag (ec2 , args .realm_tag_key , args .realm_tag_value )
169184 if not realm_id :
170185 eprint (f"❌ No running realm instance found with tag { args .realm_tag_key } ={ args .realm_tag_value } " )
171186 return 2
172187 eprint (f"Realm instance: { realm_id } " )
173188
174- realm_ip = get_public_ip_by_instance_id (ec2 , realm_id )
175- if not realm_ip :
176- eprint ("❌ Realm public IP not found" )
177- return 2
178- endpoint_url = f"https://{ realm_ip } :{ args .endpoint_port } "
179- eprint (f"Realm endpoint: { endpoint_url } " )
189+ # Endpoint host resolution
190+ if args .endpoint_host :
191+ target_host = args .endpoint_host
192+ reason = "custom host override"
193+ else :
194+ pub_ip = get_public_ip_by_instance_id (ec2 , realm_id )
195+ priv_ip = get_private_ip_by_instance_id (ec2 , realm_id )
196+
197+ if args .use_private_endpoint :
198+ if not priv_ip :
199+ eprint ("❌ --use-private-endpoint set, but no PrivateIpAddress on realm" )
200+ return 2
201+ target_host , reason = priv_ip , "private (forced)"
202+ else :
203+ if pub_ip and pub_ip != "None" :
204+ target_host , reason = pub_ip , "public"
205+ elif priv_ip :
206+ target_host , reason = priv_ip , "private (fallback)"
207+ else :
208+ eprint ("❌ Neither PublicIpAddress nor PrivateIpAddress found on realm" )
209+ return 2
210+
211+ endpoint_url = f"https://{ target_host } :{ args .endpoint_port } "
212+ eprint (f"Realm endpoint ({ reason } ): { endpoint_url } " )
180213
181214 # 2) Create S3 creds on realm (SSM → redcli)
182215 redcli_cmds = [
183- ' set -euo pipefail' ,
216+ " set -euo pipefail" ,
184217 f'redcli user login realm_admin -p "{ args .admin_password } "' ,
185218 'redcli user add s3_test_user -p Adminpassword -r red -t red || true' ,
186219 'redcli user grant s3_test_user red/red -t red || true' ,
187220 'OUT="$(redcli s3 access add s3_test_user -t red -r red/red/redobj -e 10y)"' ,
188221 'echo "===BEGIN-OUT==="' ,
189222 'echo "$OUT"' ,
190223 'echo "===END-OUT==="' ,
191- # Friendly parse lines for easier regex later
224+ # Emit parse-friendly lines:
192225 'echo "S3_KEY=$(echo "$OUT" | sed -n \' s/.*S3_KEY[^A-Za-z0-9_-]*\\ ([A-Za-z0-9_-]\\ +\\ ).*/\\ 1/p\' | head -n1)"' ,
193226 'echo "S3_SECRET=$(echo "$OUT" | sed -n \' s/.*S3_SECRET[^A-Za-z0-9+\\ /=_-]*\\ ([A-Za-z0-9+\\ /=_-]\\ +\\ ).*/\\ 1/p\' | head -n1)"' ,
194227 ]
@@ -208,11 +241,10 @@ def main():
208241 eprint ("STDOUT:\n " + realm_result ["StdOut" ])
209242 return 4
210243
211- # Do not print secrets in logs
212244 eprint (f"S3_KEY parsed: { s3_key [:4 ]} … (masked)" )
213245 eprint ("S3_SECRET parsed: **** (masked)" )
214246
215- # 3) Find clients
247+ # 3) Client instances
216248 client_ids = list_instance_ids_by_tag (ec2 , args .client_tag_key , args .client_tag_value )
217249 if not client_ids :
218250 eprint (f"❌ No running client instances found with tag { args .client_tag_key } ={ args .client_tag_value } " )
@@ -222,38 +254,43 @@ def main():
222254 # 4) S3 test on each client
223255 now = datetime .datetime .utcnow ().strftime ("%Y%m%d%H%M%S" )
224256 bucket = f"infinia-smoke-{ now } -{ random .randint (1000 ,9999 )} "
225- test_file_cmd = r'TS=$(date +%Y%m%dT%H%M%S); echo "S3 test file created at $TS" > /home/ubuntu/test-$TS.txt'
226257
227- create_list_upload_cmds = [
258+ no_verify = "--no-verify-ssl" if args .no_verify_ssl else ""
259+ # All logic happens on the client; avoid jq dependency and keep it pure AWS CLI
260+ client_cmds = [
228261 "set -euo pipefail" ,
229262 "mkdir -p /home/ubuntu/.aws && chmod 700 /home/ubuntu/.aws" ,
230- # write credentials (no echo in logs )
263+ # Write credentials (won't echo contents )
231264 f"cat > /home/ubuntu/.aws/credentials <<'EOF'\n [default]\n aws_access_key_id={ s3_key } \n aws_secret_access_key={ s3_secret } \n EOF" ,
232- test_file_cmd ,
265+ "TS=$(date +%Y%m%dT%H%M%S)" ,
266+ "FNAME=test-${TS}.txt" ,
267+ 'echo "S3 test file created at $TS" > "/home/ubuntu/$FNAME"' ,
233268 "export AWS_SHARED_CREDENTIALS_FILE=/home/ubuntu/.aws/credentials" ,
234- "aws --version || true" ,
235- f"aws { '--no-verify-ssl' if args .no_verify_ssl else '' } --endpoint-url={ endpoint_url } s3api create-bucket --bucket { bucket } || true" ,
236- r"FILE=$(ls -1t /home/ubuntu/test-*.txt | head -n1)" ,
237- f"aws { '--no-verify-ssl' if args .no_verify_ssl else '' } --endpoint-url={ endpoint_url } s3 cp \" $FILE\" s3://{ bucket } /" ,
238- f"aws { '--no-verify-ssl' if args .no_verify_ssl else '' } --endpoint-url={ endpoint_url } s3api list-objects-v2 --bucket { bucket } --output json --query 'Contents[].Key'" ,
239- "echo OK" ,
269+ # Create bucket (ignore exists)
270+ f'aws { no_verify } --endpoint-url="{ endpoint_url } " s3api create-bucket --bucket "{ bucket } " || true' ,
271+ # Upload
272+ f'aws { no_verify } --endpoint-url="{ endpoint_url } " s3 cp "/home/ubuntu/$FNAME" "s3://{ bucket } /"' ,
273+ # List and verify presence using --query (prints JSON array or "None")
274+ f'LIST=$(aws { no_verify } --endpoint-url="{ endpoint_url } " s3api list-objects-v2 --bucket "{ bucket } " --query "Contents[].Key" --output text || true)' ,
275+ 'echo "LIST:$LIST"' ,
276+ 'echo "$LIST" | grep -q "$FNAME" && echo OK' ,
240277 ]
241278
242- client_results = ssm_send_and_wait (
243- ssm , client_ids , create_list_upload_cmds , "Infinia S3 smoke test" , timeout_sec = args .timeout_sec
279+ fanout = ssm_send_and_wait (
280+ ssm , client_ids , client_cmds , "Infinia S3 smoke test" , timeout_sec = args .timeout_sec
244281 )
245282
246- had_fail = False
247- for iid , res in client_results .items ():
283+ failed = False
284+ for iid , res in fanout .items ():
248285 print (f"---- Client { iid } ({ res ['Status' ]} ) ----" )
249- # Show stdout for visibility but keep credentials masked (we never echo them above)
286+ # StdOut shows "LIST: ..." and "OK" if success; secrets are never printed
250287 print (res ["StdOut" ])
251288 if res ["Status" ] != "Success" or "OK" not in res ["StdOut" ]:
252289 eprint (f"❌ S3 smoke test failed on client { iid } " )
253290 eprint (res ["StdErr" ])
254- had_fail = True
291+ failed = True
255292
256- if had_fail :
293+ if failed :
257294 return 6
258295
259296 print ("✅ S3 smoke test passed on all clients" )
0 commit comments