Setting the file. One moment.
Test Event Publisher · AWS Marketplace Metering · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page Query Patterns
70
Creating Amazon Aurora Db Cluster With Instances
93
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
scripts/ test_event_publisher.py
Python · 263 lines · 12 KB
14 the production rule. An ``aws.*`` source is REJECTED (no default).
15
16 Guardrails (must be enforced by the deploying stack / caller):
17 * Deploy ONLY in a stage whose MeteringMode is ``dry-run`` (the submitter then never calls the
18 real BatchMeterUsage). NEVER deploy this in a ``live`` stage.
19 * ``acceptor.accountId`` is drawn ONLY from the seller-declared TEST_ACCOUNT_ALLOWLIST — never
20 a real/production buyer account.
21 * The event source is stage-scoped (never ``aws.*``); the productCode is supplied PER INVOCATION
22 (payload ``productCode``) so the SHARED events stack carries no per-product value.
23 * Publish to the same account/region as the non-prod events stack so its EventBridge rule
24 (scoped to the stage source + test accounts) picks the events up.
25
26 Scenarios (``--scenario`` / event ``scenario`` field): new, deprovision, agreement-ended,
27 agreement-amended, multi-region, multi-dimension, burst, malformed, month-boundary, duplicate.
28 """
29
30 import argparse
31 import json
32 import os
33 import random
34 import string
35 import uuid
36 from datetime import datetime, timedelta, timezone
37
38 # The reserved AWS service source — the test publisher must NOT emit under this (see module
39 # docstring); it is referenced only to REJECT it. There is deliberately no default source.
40 RESERVED_SOURCE_PREFIX = "aws."
41
42 # Detail-types the subscription Lambda consumes (must match production exactly).
43 DETAIL_TYPES = {
44 "new" : "License Updated" ,
45 "deprovision" : "License Deprovisioned" ,
46 "agreement-ended" : "Purchase Agreement Ended" ,
47 "agreement-amended" : "Purchase Agreement Amended" ,
48 }
49
50 SCENARIOS = [
51 "new" ,
52 "deprovision" ,
53 "agreement-ended" ,
54 "agreement-amended" ,
55 "multi-region" ,
56 "multi-dimension" ,
57 "burst" ,
58 "malformed" ,
59 "month-boundary" ,
60 "duplicate" ,
61 ]
62
63 # A small pool of well-formed sample regions/dimensions for randomized-but-valid values.
64 _SAMPLE_REGIONS = [ "us-east-1" , "us-west-2" , "eu-west-1" , "ap-southeast-2" ]
65 _SAMPLE_DIMENSIONS = [ "Requests" , "GBHours" , "Users" , "DataProcessedGB" ]
66
67
68 def _rand_account_id ():
69 """A syntactically valid 12-digit AWS account id (for shape only)."""
70 return "" .join(random.choices(string.digits, k = 12 ))
71
72
73 def _rand_license_arn (account_id):
74 return f "arn:aws:license-manager:: { account_id } :license:l- { uuid.uuid4().hex[: 16 ] } "
75
76
77 def _rand_agreement_id ():
78 return "agmt-" + uuid.uuid4().hex[: 20 ]
79
80
81 def _iso (dt):
82 return dt.strftime( "%Y-%m- %d T%H:%M:%SZ" )
83
84
85 def _require_event_source (event_source):
86 """A stage-scoped event source is REQUIRED — there is no default, and an ``aws.*`` source is
87 rejected (a custom PutEvents cannot reliably deliver the reserved AWS-service prefix, and it
88 would collide with the real production source)."""
89 if not event_source:
90 raise ValueError ( "event_source is required (a stage-scoped '<stageName>.agreement-marketplace'); "
91 "there is no default" )
92 if event_source.startswith( RESERVED_SOURCE_PREFIX ):
93 raise ValueError ( f "event_source ' { event_source } ' uses the reserved 'aws.' prefix — use a "
94 "stage-scoped '<stageName>.agreement-marketplace' instead" )
95 return event_source
96
97
98 def build_event (scenario, test_accounts, product_code, now = None , event_source = None ):
99 """Build ONE EventBridge event dict in AWS Marketplace shape for the given scenario.
100
101 ``test_accounts`` is the REQUIRED non-empty allowlist of test buyer account ids — the
102 acceptor is chosen only from it (test accounts only). ``event_source`` is REQUIRED and must be
103 a stage-scoped source (never the reserved ``aws.`` prefix); the detail shape matches
104 production. Returns the event dict (not yet published).
105 """
106 if not test_accounts:
107 raise ValueError ( "test_accounts (TestAccountAllowlist) must be non-empty — non-prod "
108 "test events may use test accounts ONLY" )
109 event_source = _require_event_source(event_source)
110 now = now or datetime.now(timezone.utc)
111 account_id = random.choice(test_accounts)
112 license_arn = _rand_license_arn(account_id)
113 agreement_id = _rand_agreement_id()
114
115 # Base detail shared by the lifecycle events.
116 detail = {
117 "acceptor" : { "accountId" : account_id},
118 "license" : { "arn" : license_arn},
119 "agreement" : { "id" : agreement_id, "endTime" : _iso(now + timedelta( days = 365 ))},
120 "product" : { "code" : product_code},
121 }
122
123 # Map compound scenarios onto a concrete detail-type. These use a License Updated envelope;
124 # 'malformed' then corrupts it below, the others vary the USAGE-row side.
125 detail_type_key = scenario
126 if scenario in ( "multi-region" , "multi-dimension" , "burst" , "duplicate" , "month-boundary" , "malformed" ):
127 detail_type_key = "new"
128
129 detail_type = DETAIL_TYPES .get(detail_type_key, "License Updated" )
130
131 if scenario == "deprovision" :
132 # Deprovision opens the ~1h flush window; the subscription Lambda derives expiry from time.
133 detail[ "entitlement" ] = { "status" : "deprovisioning" }
134 elif detail_type == "License Updated" :
135 detail[ "entitlement" ] = { "status" : "active" , "dimension" : random.choice( _SAMPLE_DIMENSIONS )}
136
137 if scenario == "malformed" :
138 # Emit a GENUINELY malformed License Updated event so the non-prod subscription Lambda's
139 # bad-input handling is exercised (it should reject/skip and DLQ, not crash): drop the
140 # REQUIRED license.arn entirely and corrupt acceptor.accountId to a non-12-digit value.
141 detail[ "license" ].pop( "arn" , None )
142 detail[ "acceptor" ][ "accountId" ] = "not-an-account-id"
143
144 event = {
145 "Source" : event_source,
146 "DetailType" : detail_type,
147 "Detail" : json.dumps(detail),
148 # Non-standard helper fields the seeder/consumer may read for the usage-row side; they
149 # do NOT appear in real Marketplace events and are ignored by the subscription Lambda.
150 "scenario" : scenario,
151 "time" : _iso(now),
152 }
153 return event
154
155
156 def build_batch (scenario, test_accounts, product_code, count = 1 , now = None , event_source = None ):
157 """Build N events for a scenario. ``burst`` implies many; ``duplicate`` repeats one event."""
158 now = now or datetime.now(timezone.utc)
159 if scenario == "burst" and count == 1 :
160 count = 50
161 events = [build_event(scenario, test_accounts, product_code, now, event_source) for _ in range (count)]
162 if scenario == "duplicate" and events:
163 # Emit the SAME event twice to exercise idempotency/first-write-wins downstream.
164 events.append(json.loads(json.dumps(events[ 0 ])))
165 return events
166
167
168 def _publish (events, event_bus_name = None , region = None ):
169 import boto3
170
171 client = boto3.client( "events" , region_name = region) if region else boto3.client( "events" )
172 sent = 0
173 for i in range ( 0 , len (events), 10 ): # PutEvents max 10 entries/call
174 chunk = events[i:i + 10 ]
175 entries = []
176 for e in chunk:
177 entry = { "Source" : e[ "Source" ], "DetailType" : e[ "DetailType" ], "Detail" : e[ "Detail" ]}
178 if event_bus_name:
179 entry[ "EventBusName" ] = event_bus_name
180 entries.append(entry)
181 resp = client.put_events( Entries = entries)
182 failed = resp.get( "FailedEntryCount" , 0 )
183 if failed:
184 raise RuntimeError ( f "PutEvents reported { failed } failed entr(ies): { resp } " )
185 sent += len (entries)
186 return sent
187
188
189 def handler (event, context):
190 """Lambda entrypoint: generate + publish per the invocation payload.
191
192 Invocation payload (the JSON you pass when invoking the Lambda), e.g.::
193
194 {"scenario": "new", "count": 3, "productCode": "abcd1234efgh5678"}
195
196 - ``productCode`` (REQUIRED) — the AWS Marketplace product code to stamp on the simulated
197 events' ``detail.product.code``. It is supplied PER INVOCATION (NOT a stack parameter or an
198 environment variable) on purpose: the events stack is SHARED by every product in the stage,
199 so a single deployed publisher can simulate ANY product just by passing a different
200 ``productCode`` per invocation — and there is no shared per-product value that a second
201 product's deploy could overwrite. How callers pass it:
202 * AWS CLI: aws lambda invoke --function-name awsmp-events-<stage>-test-event-publisher \\
203 --payload '{"scenario":"new","productCode":"<code>"}' out.json
204 * Console: Lambda > Test > event JSON ``{"scenario":"new","productCode":"<code>"}``
205 * The generated test plan / seeder passes the product under test.
206 There is NO default and NO env fallback — a missing productCode raises (fail fast) rather
207 than silently stamping the wrong/empty product.
208 - ``scenario`` (default ``new``) and ``count`` (default 1) select the scenario + how many events.
209
210 TEST_ACCOUNT_ALLOWLIST, EVENT_SOURCE, and EVENT_BUS_NAME come from the environment (set by the
211 stack only in a dry-run stage).
212 """
213 if os.environ.get( "METERING_MODE" , "" ).strip().lower() != "dry-run" :
214 raise RuntimeError ( "test_event_publisher may run ONLY in a dry-run (non-prod) stage" )
215 test_accounts = [a.strip() for a in os.environ.get( "TEST_ACCOUNT_ALLOWLIST" , "" ).split( "," ) if a.strip()]
216 # productCode is PER INVOCATION (payload) — never a stack param/env. This lets ONE shared
217 # publisher simulate any product in the stage with no shared per-product value to overwrite.
218 product_code = (event or {}).get( "productCode" )
219 if not product_code:
220 raise RuntimeError (
221 "productCode is required in the invocation payload, e.g. "
222 "{ \" scenario \" : \" new \" , \" productCode \" : \" <awsMarketplaceProductCode> \" }"
223 )
224 # Stage-scoped source (e.g. "beta.agreement-marketplace"); required, never the reserved "aws." prefix.
225 event_source = _require_event_source(os.environ.get( "EVENT_SOURCE" , "" ).strip())
226 scenario = (event or {}).get( "scenario" , "new" )
227 count = int ((event or {}).get( "count" , 1 ))
228 events = build_batch(scenario, test_accounts, product_code, count, event_source = event_source)
229 sent = _publish(events, event_bus_name = os.environ.get( "EVENT_BUS_NAME" ) or None )
230 return { "scenario" : scenario, "productCode" : product_code, "published" : sent}
231
232
233 def main ():
234 p = argparse.ArgumentParser( description = "Publish AWS-Marketplace-shape TEST events (non-prod only)" )
235 p.add_argument( "--scenario" , choices = SCENARIOS , default = "new" )
236 p.add_argument( "--count" , type = int , default = 1 )
237 p.add_argument( "--product-code" , required = True )
238 p.add_argument( "--test-accounts" , required = True ,
239 help = "Comma-separated test buyer account ids (allowlist)" )
240 p.add_argument( "--region" )
241 p.add_argument( "--event-bus-name" )
242 p.add_argument( "--event-source" , required = True ,
243 help = "Event Source — REQUIRED, a stage-scoped '<stageName>.agreement-marketplace' "
244 "(the reserved 'aws.' prefix is rejected)" )
245 p.add_argument( "--dry-print" , action = "store_true" ,
246 help = "Print the generated events instead of publishing" )
247 args = p.parse_args()
248 try :
249 _require_event_source(args.event_source)
250 except ValueError as e:
251 p.error( str (e))
252 accounts = [a.strip() for a in args.test_accounts.split( "," ) if a.strip()]
253 events = build_batch(args.scenario, accounts, args.product_code, args.count,
254 event_source = args.event_source)
255 if args.dry_print:
256 print (json.dumps(events, indent = 2 ))
257 return
258 sent = _publish(events, event_bus_name = args.event_bus_name, region = args.region)
259 print ( f "Published { sent } ' { args.scenario } ' test event(s)." )
260
261
262 if __name__ == "__main__" :
263 main()