|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +""" |
| 5 | +End-to-end S3 API smoke test for Infinia, driven via AWS SSM. |
| 6 | +
|
| 7 | +Flow: |
| 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. |
| 11 | +4) 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>. |
| 28 | +""" |
| 29 | + |
| 30 | +import argparse |
| 31 | +import os |
| 32 | +import re |
| 33 | +import sys |
| 34 | +import time |
| 35 | +import json |
| 36 | +import random |
| 37 | +import datetime |
| 38 | + |
| 39 | +import boto3 |
| 40 | +from botocore.exceptions import ClientError |
| 41 | + |
| 42 | + |
| 43 | +def eprint(*args, **kwargs): |
| 44 | + print(*args, file=sys.stderr, **kwargs) |
| 45 | + |
| 46 | + |
| 47 | +def find_one_instance_id_by_tag(ec2, key, value): |
| 48 | + resp = ec2.describe_instances( |
| 49 | + Filters=[ |
| 50 | + {"Name": f"tag:{key}", "Values": [value]}, |
| 51 | + {"Name": "instance-state-name", "Values": ["running"]}, |
| 52 | + ], |
| 53 | + MaxResults=50, |
| 54 | + ) |
| 55 | + for r in resp.get("Reservations", []): |
| 56 | + for i in r.get("Instances", []): |
| 57 | + return i["InstanceId"] |
| 58 | + return None |
| 59 | + |
| 60 | + |
| 61 | +def get_public_ip_by_instance_id(ec2, instance_id): |
| 62 | + resp = ec2.describe_instances(InstanceIds=[instance_id]) |
| 63 | + inst = resp["Reservations"][0]["Instances"][0] |
| 64 | + return inst.get("PublicIpAddress") |
| 65 | + |
| 66 | + |
| 67 | +def list_instance_ids_by_tag(ec2, key, value): |
| 68 | + ids = [] |
| 69 | + paginator = ec2.get_paginator("describe_instances") |
| 70 | + for page in paginator.paginate( |
| 71 | + Filters=[ |
| 72 | + {"Name": f"tag:{key}", "Values": [value]}, |
| 73 | + {"Name": "instance-state-name", "Values": ["running"]}, |
| 74 | + ] |
| 75 | + ): |
| 76 | + for r in page.get("Reservations", []): |
| 77 | + for i in r.get("Instances", []): |
| 78 | + ids.append(i["InstanceId"]) |
| 79 | + return ids |
| 80 | + |
| 81 | + |
| 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. |
| 84 | +
|
| 85 | + Returns dict: {instance_id: {"Status": str, "StdOut": str, "StdErr": str}} |
| 86 | + """ |
| 87 | + if isinstance(instance_ids, str): |
| 88 | + instance_ids = [instance_ids] |
| 89 | + |
| 90 | + resp = ssm.send_command( |
| 91 | + DocumentName="AWS-RunShellScript", |
| 92 | + InstanceIds=instance_ids, |
| 93 | + Comment=comment, |
| 94 | + Parameters={"commands": commands}, |
| 95 | + ) |
| 96 | + cmd_id = resp["Command"]["CommandId"] |
| 97 | + |
| 98 | + deadline = time.time() + timeout_sec |
| 99 | + results = {iid: None for iid in instance_ids} |
| 100 | + |
| 101 | + while time.time() < deadline: |
| 102 | + done = True |
| 103 | + for iid in instance_ids: |
| 104 | + if results[iid] is not None: |
| 105 | + continue |
| 106 | + try: |
| 107 | + inv = ssm.get_command_invocation(CommandId=cmd_id, InstanceId=iid) |
| 108 | + except ssm.exceptions.InvocationDoesNotExist: |
| 109 | + done = False |
| 110 | + continue |
| 111 | + |
| 112 | + status = inv.get("Status") |
| 113 | + if status in ("Success", "Failed", "Cancelled", "TimedOut"): |
| 114 | + results[iid] = { |
| 115 | + "Status": status, |
| 116 | + "StdOut": inv.get("StandardOutputContent", ""), |
| 117 | + "StdErr": inv.get("StandardErrorContent", ""), |
| 118 | + } |
| 119 | + else: |
| 120 | + done = False |
| 121 | + |
| 122 | + if done: |
| 123 | + break |
| 124 | + time.sleep(poll_sec) |
| 125 | + |
| 126 | + # Fill any missing as timeout |
| 127 | + for iid in instance_ids: |
| 128 | + if results[iid] is None: |
| 129 | + results[iid] = {"Status": "TimedOut", "StdOut": "", "StdErr": ""} |
| 130 | + |
| 131 | + return results |
| 132 | + |
| 133 | + |
| 134 | +def parse_redcli_creds_from_text(text): |
| 135 | + """ |
| 136 | + Extract S3_KEY and S3_SECRET from redcli pretty-table-like output. |
| 137 | +
|
| 138 | + Accepts lines like: |
| 139 | + │ S3_KEY │ AKIA... │ |
| 140 | + │ S3_SECRET │ wJalrXUtnFEMI/K7MDENG/bPxRfiCY... │ |
| 141 | + """ |
| 142 | + key_match = re.search(r"S3_KEY[^\w-]*([A-Za-z0-9_-]+)", text) |
| 143 | + sec_match = re.search(r"S3_SECRET[^\w+/=_-]*([A-Za-z0-9+/=_-]+)", text) |
| 144 | + s3_key = key_match.group(1) if key_match else "" |
| 145 | + s3_secret = sec_match.group(1) if sec_match else "" |
| 146 | + return s3_key.strip(), s3_secret.strip() |
| 147 | + |
| 148 | + |
| 149 | +def main(): |
| 150 | + ap = argparse.ArgumentParser(description="Infinia S3 API smoke test via SSM") |
| 151 | + ap.add_argument("--region", required=True, help="AWS region (e.g. us-east-1)") |
| 152 | + ap.add_argument("--admin-password", required=True, help="Infinia realm_admin password") |
| 153 | + ap.add_argument("--realm-tag-key", default="Role") |
| 154 | + ap.add_argument("--realm-tag-value", default="realm") |
| 155 | + ap.add_argument("--client-tag-key", default="Role") |
| 156 | + 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) |
| 161 | + args = ap.parse_args() |
| 162 | + |
| 163 | + session = boto3.Session(region_name=args.region) |
| 164 | + ec2 = session.client("ec2") |
| 165 | + ssm = session.client("ssm") |
| 166 | + |
| 167 | + # 1) Realm |
| 168 | + realm_id = find_one_instance_id_by_tag(ec2, args.realm_tag_key, args.realm_tag_value) |
| 169 | + if not realm_id: |
| 170 | + eprint(f"❌ No running realm instance found with tag {args.realm_tag_key}={args.realm_tag_value}") |
| 171 | + return 2 |
| 172 | + eprint(f"Realm instance: {realm_id}") |
| 173 | + |
| 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}") |
| 180 | + |
| 181 | + # 2) Create S3 creds on realm (SSM → redcli) |
| 182 | + redcli_cmds = [ |
| 183 | + 'set -euo pipefail', |
| 184 | + f'redcli user login realm_admin -p "{args.admin_password}"', |
| 185 | + 'redcli user add s3_test_user -p Adminpassword -r red -t red || true', |
| 186 | + 'redcli user grant s3_test_user red/red -t red || true', |
| 187 | + 'OUT="$(redcli s3 access add s3_test_user -t red -r red/red/redobj -e 10y)"', |
| 188 | + 'echo "===BEGIN-OUT==="', |
| 189 | + 'echo "$OUT"', |
| 190 | + 'echo "===END-OUT==="', |
| 191 | + # Friendly parse lines for easier regex later |
| 192 | + 'echo "S3_KEY=$(echo "$OUT" | sed -n \'s/.*S3_KEY[^A-Za-z0-9_-]*\\([A-Za-z0-9_-]\\+\\).*/\\1/p\' | head -n1)"', |
| 193 | + 'echo "S3_SECRET=$(echo "$OUT" | sed -n \'s/.*S3_SECRET[^A-Za-z0-9+\\/=_-]*\\([A-Za-z0-9+\\/=_-]\\+\\).*/\\1/p\' | head -n1)"', |
| 194 | + ] |
| 195 | + realm_result = ssm_send_and_wait( |
| 196 | + ssm, realm_id, redcli_cmds, "Create s3_test_user and S3 access", timeout_sec=min(args.timeout_sec, 600) |
| 197 | + )[realm_id] |
| 198 | + |
| 199 | + if realm_result["Status"] != "Success": |
| 200 | + eprint("❌ redcli SSM command failed on realm") |
| 201 | + eprint("STDERR:\n" + realm_result["StdErr"]) |
| 202 | + eprint("STDOUT:\n" + realm_result["StdOut"]) |
| 203 | + return 3 |
| 204 | + |
| 205 | + s3_key, s3_secret = parse_redcli_creds_from_text(realm_result["StdOut"]) |
| 206 | + if not s3_key or not s3_secret: |
| 207 | + eprint("❌ Failed to parse S3 credentials from realm output") |
| 208 | + eprint("STDOUT:\n" + realm_result["StdOut"]) |
| 209 | + return 4 |
| 210 | + |
| 211 | + # Do not print secrets in logs |
| 212 | + eprint(f"S3_KEY parsed: {s3_key[:4]}… (masked)") |
| 213 | + eprint("S3_SECRET parsed: **** (masked)") |
| 214 | + |
| 215 | + # 3) Find clients |
| 216 | + client_ids = list_instance_ids_by_tag(ec2, args.client_tag_key, args.client_tag_value) |
| 217 | + if not client_ids: |
| 218 | + eprint(f"❌ No running client instances found with tag {args.client_tag_key}={args.client_tag_value}") |
| 219 | + return 5 |
| 220 | + eprint(f"Client instances: {', '.join(client_ids)}") |
| 221 | + |
| 222 | + # 4) S3 test on each client |
| 223 | + now = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") |
| 224 | + 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' |
| 226 | + |
| 227 | + create_list_upload_cmds = [ |
| 228 | + "set -euo pipefail", |
| 229 | + "mkdir -p /home/ubuntu/.aws && chmod 700 /home/ubuntu/.aws", |
| 230 | + # write credentials (no echo in logs) |
| 231 | + f"cat > /home/ubuntu/.aws/credentials <<'EOF'\n[default]\naws_access_key_id={s3_key}\naws_secret_access_key={s3_secret}\nEOF", |
| 232 | + test_file_cmd, |
| 233 | + "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", |
| 240 | + ] |
| 241 | + |
| 242 | + client_results = ssm_send_and_wait( |
| 243 | + ssm, client_ids, create_list_upload_cmds, "Infinia S3 smoke test", timeout_sec=args.timeout_sec |
| 244 | + ) |
| 245 | + |
| 246 | + had_fail = False |
| 247 | + for iid, res in client_results.items(): |
| 248 | + print(f"---- Client {iid} ({res['Status']}) ----") |
| 249 | + # Show stdout for visibility but keep credentials masked (we never echo them above) |
| 250 | + print(res["StdOut"]) |
| 251 | + if res["Status"] != "Success" or "OK" not in res["StdOut"]: |
| 252 | + eprint(f"❌ S3 smoke test failed on client {iid}") |
| 253 | + eprint(res["StdErr"]) |
| 254 | + had_fail = True |
| 255 | + |
| 256 | + if had_fail: |
| 257 | + return 6 |
| 258 | + |
| 259 | + print("✅ S3 smoke test passed on all clients") |
| 260 | + return 0 |
| 261 | + |
| 262 | + |
| 263 | +if __name__ == "__main__": |
| 264 | + sys.exit(main()) |
0 commit comments