Setting the file. One moment.
Deploy · 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/ deploy.sh
Shell · 553 lines · 27 KB
15 #
16 # Stack names (stage-scoped; STAGE_NAME is mandatory):
17 # Events stack (shared per stage): awsmp-events-<STAGE_NAME>-stack (us-east-1)
18 # Main stack (per-product): <PREFIX>-metering, default awsmp-<PRODUCT_CODE>-<STAGE_NAME>-metering (<REGION>)
19
20 # ─── Cleanup trap ────────────────────────────────────────────────────────────
21 cleanup () {
22 local exit_code = $?
23 if [ $exit_code -ne 0 ]; then
24 echo ""
25 echo "⚠️ Deployment failed (exit code: $exit_code )"
26 echo " Check the CloudFormation console for stack events."
27 echo " No automatic rollback — review and fix before retrying."
28 fi
29 # Clean up SAM build artifacts if they exist
30 if [ -d ".aws-sam" ]; then
31 rm -rf .aws-sam
32 fi
33 exit $exit_code
34 }
35 trap cleanup EXIT
36
37 # ─── Version checks ─────────────────────────────────────────────────────────
38 check_prerequisites () {
39 local errors = 0
40
41 # AWS CLI v2 required
42 if ! command -v aws & > /dev/null; then
43 echo "ERROR: AWS CLI not found. Install from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
44 errors = $(( errors + 1 ))
45 else
46 local aws_version
47 aws_version = $( aws --version 2>&1 | sed -n 's/.*aws-cli\/\([0-9]*\).*/\1/p' )
48 if [ "${ aws_version :- 0 }" -lt 2 ]; then
49 echo "ERROR: AWS CLI v2 required (found v${ aws_version }). Update: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
50 errors = $(( errors + 1 ))
51 fi
52 fi
53
54 # SAM CLI required
55 if ! command -v sam & > /dev/null; then
56 echo "ERROR: SAM CLI not found. Install from https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html"
57 errors = $(( errors + 1 ))
58 fi
59
60 # Python 3.9+ required (for Lambda runtime compatibility)
61 if ! command -v python3 & > /dev/null; then
62 echo "ERROR: Python 3 not found."
63 errors = $(( errors + 1 ))
64 else
65 local py_version
66 py_version = $( python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" )
67 local py_minor
68 py_minor = $( echo " $py_version " | cut -d. -f2 )
69 if [ "${ py_minor :- 0 }" -lt 9 ]; then
70 echo "WARNING: Python 3.9+ recommended (found $py_version ). Lambda uses 3.12."
71 fi
72 fi
73
74 if [ $errors -gt 0 ]; then
75 echo ""
76 echo "Fix the above errors before proceeding."
77 exit 1
78 fi
79 }
80
81 # ─── Input validation ─────────────────────────────────────────────
82 # Validate caller-supplied values against a strict allowlist charset BEFORE use, so a
83 # value containing spaces/shell metacharacters cannot inject extra CloudFormation
84 # parameter overrides or arguments. Product code / prefix: alphanumeric, dash, underscore.
85 # Region: AWS region format.
86 validate_inputs () {
87 local product_code = " $1 "
88 local region = " $2 "
89 local prefix = " $3 "
90
91 if ! [[ " $product_code " =~ ^[A-Za-z0-9_-]+$ ]]; then
92 echo "ERROR: invalid PRODUCT_CODE ' $product_code ' (allowed: A-Z a-z 0-9 _ -)."
93 exit 1
94 fi
95 if ! [[ " $region " =~ ^[a-z]{ 2 }-[a-z]+-[0-9]$ ]]; then
96 echo "ERROR: invalid REGION ' $region ' (expected e.g. us-east-1)."
97 exit 1
98 fi
99 if ! [[ " $prefix " =~ ^[A-Za-z0-9_-]+$ ]]; then
100 echo "ERROR: invalid PREFIX ' $prefix ' (allowed: A-Z a-z 0-9 _ -)."
101 exit 1
102 fi
103 }
104
105 # ─── SAM artifact bucket (no --resolve-s3) ──────────────────────────
106 # The least-privilege deployer policy scopes cloudformation:* to stack/awsmp-*, so
107 # `sam deploy --resolve-s3` (which bootstraps the non-awsmp aws-sam-cli-managed-default
108 # CFN stack) is DENIED. Provision the artifact bucket DIRECTLY instead — the policy's
109 # S3DeploymentArtifacts grant already allows this on aws-sam-cli-managed-default-*.
110 # Echoes the bucket name for use with `sam deploy --s3-bucket`.
111 ensure_sam_bucket () {
112 local region = " $1 "
113 local bucket = "aws-sam-cli-managed-default-${ ACCOUNT_ID }-${ region }"
114 if ! aws s3api head-bucket --bucket " $bucket " --region " $region " > /dev/null 2>&1 ; then
115 if [ " $region " = "us-east-1" ]; then
116 aws s3api create-bucket --bucket " $bucket " --region " $region " > /dev/null
117 else
118 aws s3api create-bucket --bucket " $bucket " --region " $region " \
119 --create-bucket-configuration "LocationConstraint=${ region }" > /dev/null
120 fi
121 aws s3api put-bucket-encryption --bucket " $bucket " \
122 --server-side-encryption-configuration \
123 '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' > /dev/null
124 aws s3api put-bucket-versioning --bucket " $bucket " \
125 --versioning-configuration Status=Enabled > /dev/null
126 fi
127 echo " $bucket "
128 }
129
130 # ─── Per-region API Gateway CloudWatch Logs account role ────────────
131 # A logging-enabled API stage (AccessLogSetting) requires a per-region,
132 # account-level CloudWatch Logs role set via `apigateway update-account`. Granting the
133 # permission is not enough — the role/account setting must actually be provisioned per
134 # region, or the first region "just works" and every additional region fails with
135 # "CloudWatch Logs role ARN must be set in account settings to enable logging".
136 # Idempotent: creates the (region-agnostic) role once, then sets the account setting for
137 # THIS region if not already set.
138 ensure_apigw_cw_role () {
139 local region = " $1 "
140 local role_name = "awsmp-apigw-cloudwatch-logs"
141 local role_arn = "arn:aws:iam::${ ACCOUNT_ID }:role/${ role_name }"
142
143 if ! aws iam get-role --role-name " $role_name " > /dev/null 2>&1 ; then
144 aws iam create-role --role-name " $role_name " \
145 --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"apigateway.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
146 > /dev/null
147 aws iam attach-role-policy --role-name " $role_name " \
148 --policy-arn arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs \
149 > /dev/null
150 # IAM role propagation to API Gateway can lag a few seconds.
151 sleep 10
152 fi
153
154 local current
155 current = $( aws apigateway get-account --region " $region " \
156 --query cloudwatchRoleArn --output text 2> /dev/null || echo "None" )
157 if [ " $current " = "None" ] || [ -z " $current " ]; then
158 echo "Setting API Gateway account CloudWatch Logs role for ${ region } (account-global for that region)..."
159 aws apigateway update-account --region " $region " \
160 --patch-operations "op=replace,path=/cloudwatchRoleArn,value=${ role_arn }" > /dev/null
161 fi
162 }
163
164 # ─── Failed-deploy recovery preflight (detect-and-guide only) ────────
165 # After a failed deploy/rollback, DeletionPolicy: Retain resources (usage/subscribers
166 # tables, subscription role, /aws/lambda/awsmp-* log groups) and an orphaned baseline
167 # WebACL can survive and collide on retry (ResourceExistenceCheck / WebACL AlreadyExists).
168 # This is READ-ONLY: it DETECTS likely collisions and prints guidance, then continues.
169 # It NEVER auto-deletes; the seller removes reviewed resources with their own credentials
170 # (a data-bearing table requires explicit confirmation).
171 warn_retained_resources () {
172 local prefix = " $1 "
173 local region = " $2 "
174 local found = 0
175 # Log groups are the easy-to-miss case (not shown in the rollback resource summary).
176 local lg
177 lg = $( aws logs describe-log-groups --region " $region " \
178 --log-group-name-prefix "/aws/lambda/${ prefix }-" \
179 --query 'logGroups[].logGroupName' --output text 2> /dev/null || true )
180 if [ -n " $lg " ] && [ " $lg " != "None" ]; then
181 echo "NOTE: pre-existing log group(s) may collide on re-create: $lg "
182 found = 1
183 fi
184 if aws dynamodb describe-table --table-name "${ prefix }-usage" --region " $region " > /dev/null 2>&1 ; then
185 echo "NOTE: usage table '${ prefix }-usage' already exists (retained). If this is a"
186 echo " re-deploy after a FAILED first deploy the table is empty and can be removed;"
187 echo " if it holds billable usage do NOT delete it. Deleting requires your explicit"
188 echo " action with your own credentials — this script will not delete it."
189 found = 1
190 fi
191 if [ $found -eq 1 ]; then
192 echo " Review and remove the reviewed leftover resource(s), then retry."
193 fi
194 }
195
196 # ─── Materialize-then-deploy parity preflight ────────────────────────────────
197 # This script deploys the on-disk SAM template(s) the skill materialized into the
198 # workspace (`sam build --template-file <name>` below), so the deployed stack always
199 # matches the source the seller can see and further develop. Fail fast (READ-ONLY, no
200 # mutation) if the template we are about to build is missing or renamed — that would
201 # mean a drift between "what was generated" and "what gets deployed". We do NOT recreate
202 # or relocate it; the seller/skill regenerates it in place.
203 require_template () {
204 local tmpl = " $1 "
205 if [ ! -f " $tmpl " ]; then
206 echo "ERROR: expected on-disk template ' $tmpl ' not found in $( pwd )." >&2
207 echo " deploy.sh deploys the materialized template so the stack matches your" >&2
208 echo " source. Generate/restore ' $tmpl ' (and its Lambda handlers) here," >&2
209 echo " then re-run — do not deploy a template that is not left in the workspace." >&2
210 exit 1
211 fi
212 }
213
214 # ─── Deploy events stack (shared, us-east-1) ─────────────────────────────────
215 # ─── Permissions-boundary preflight ──────────────────────────────────────────
216 # SAM auto-generates the Lambda execution roles; the deployer role only allows
217 # iam:CreateRole when a matching permissions boundary is attached. Fail fast with a
218 # clear message (instead of an opaque AccessDenied mid-deploy) if the boundary policy
219 # named by BOUNDARY_NAME does not exist in the target account.
220 BOUNDARY_NAME = "${ BOUNDARY_NAME :- awsmp-metering-boundary }"
221
222 verify_boundary_exists () {
223 local acct boundary_arn err
224 # Validate the account id before building the ARN — an empty/None value would produce a
225 # malformed ARN and a misleading "not found" message. `|| true` so a failing STS call
226 # falls through to the guard below instead of aborting the script under `set -e`.
227 acct = $( aws sts get-caller-identity --query Account --output text 2> /dev/null || true )
228 if [ -z " $acct " ] || [ " $acct " = "None" ]; then
229 echo "ERROR: unable to resolve AWS account id (check credentials/region)."
230 exit 1
231 fi
232 boundary_arn = "arn:aws:iam::${ acct }:policy/${ BOUNDARY_NAME }"
233
234 # Capture stderr so we can distinguish a genuinely-missing policy (NoSuchEntity) from
235 # a permissions gap (AccessDenied). Collapsing every failure into "not found" would
236 # block deploys when the deployer merely lacks iam:GetPolicy — the opaque failure this
237 # preflight is meant to prevent.
238 if err = $( aws iam get-policy --policy-arn " $boundary_arn " 2>&1 > /dev/null ); then
239 echo "Permissions boundary: ${ boundary_arn }"
240 return 0
241 fi
242 case " $err " in
243 * NoSuchEntity * )
244 echo "ERROR: permissions boundary '${ BOUNDARY_NAME }' not found (${ boundary_arn })."
245 echo " Create it before deploying (see references/iam-credentials.md):"
246 echo " aws iam create-policy --policy-name ${ BOUNDARY_NAME } --policy-document file://boundary.json"
247 echo " The deployer role's iam:CreateRole is gated on this exact boundary, so"
248 echo " SAM role creation would otherwise fail with AccessDenied."
249 exit 1
250 ;;
251 * AccessDenied *|* not \ authorized * )
252 echo "WARN: cannot verify boundary '${ boundary_arn }' (iam:GetPolicy denied); continuing."
253 echo " Grant iam:GetPolicy on the boundary ARN to enable this preflight, or"
254 echo " ensure the boundary policy exists before deploying."
255 return 0
256 ;;
257 *)
258 echo "ERROR: failed to verify boundary '${ boundary_arn }': ${ err }"
259 exit 1
260 ;;
261 esac
262 }
263
264 # ─── Deploy events stack (shared, us-east-1) ─────────────────────────────────
265 deploy_events_stack () {
266 echo ""
267 # The (stage-scoped) events stack is always deployed here. `sam deploy` is idempotent
268 # (`--no-fail-on-empty-changeset` makes an unchanged stack a no-op change set), and a
269 # requested MeteringMode / TestAccountAllowlist / EventSource change MUST be applied — so
270 # there is deliberately NO existence-skip / SKIP_EVENTS flag (an existence-skip would
271 # silently suppress those updates). The deploy role needs cloudformation:GetTemplateSummary
272 # for the re-deploy of an existing stack.
273 echo "=== Deploying events stack ${ EVENTS_STACK_NAME } to us-east-1 (shared across all products for this stage) ==="
274 verify_boundary_exists
275 local ev_bucket
276 ev_bucket = $( ensure_sam_bucket us-east-1 )
277 require_template template-events.yaml
278 sam build --template-file template-events.yaml --region us-east-1
279 sam deploy \
280 --template-file .aws-sam/build/template.yaml \
281 --stack-name " $EVENTS_STACK_NAME " \
282 --region us-east-1 \
283 --capabilities CAPABILITY_NAMED_IAM \
284 --s3-bucket " $ev_bucket " \
285 --no-confirm-changeset \
286 --no-fail-on-empty-changeset \
287 --parameter-overrides "StackPrefix= $EVENTS_PREFIX " "PermissionsBoundaryName=${ BOUNDARY_NAME }" "CreateDashboard=${ CREATE_DASHBOARD :- true }" "MeteringMode= $METERING_MODE " "TestAccountAllowlist= $TEST_ACCOUNT_ALLOWLIST " "EventSource= $EVENT_SOURCE "
288 # NOTE: ProductCode is deliberately NOT passed to the SHARED events stack — the dry-run test
289 # publisher takes productCode PER INVOCATION (payload), so a second product's deploy cannot
290 # overwrite a shared per-product value (the events-stack ProductCode param stays empty).
291
292 echo "✅ Events stack deployed"
293 }
294
295 # ─── Deploy main stack (per-product, seller's region) ─────────────────────────
296 deploy_main_stack () {
297 local product_code = " $1 "
298 local region = " $2 "
299 local prefix = " $3 "
300
301 # Get subscribers table name from events stack
302 local subscribers_table
303 subscribers_table = $( aws cloudformation describe-stacks \
304 --stack-name " $EVENTS_STACK_NAME " \
305 --region us-east-1 \
306 --query 'Stacks[0].Outputs[?OutputKey==`SubscribersTableName`].OutputValue' \
307 --output text )
308
309 if [ -z " $subscribers_table " ] || [ " $subscribers_table " = "None" ]; then
310 echo "ERROR: Could not get SubscribersTableName from events stack."
311 echo " Deploy the events stack first: ./deploy.sh events-only"
312 exit 1
313 fi
314 echo "Subscribers table: $subscribers_table "
315
316 echo ""
317 echo "=== Deploying main stack to $region ==="
318 verify_boundary_exists
319 # read-only recovery preflight — detect retained/orphaned resources that
320 # would collide on retry and print guidance (never auto-deletes).
321 warn_retained_resources " $prefix " " $region "
322 # ensure the per-region API Gateway CloudWatch Logs account role/setting
323 # exists before deploying the logging-enabled stage (multi-region correctness).
324 ensure_apigw_cw_role " $region "
325 local main_bucket
326 main_bucket = $( ensure_sam_bucket " $region " )
327 require_template template.yaml
328 sam build --template-file template.yaml --region " $region "
329 # Pass overrides as discrete, individually-quoted Key=Value tokens (array form) so a
330 # value containing spaces cannot inject additional overrides. Values are
331 # already validated by validate_inputs().
332 local overrides = (
333 "ProductCode= $product_code "
334 "StackPrefix= $prefix "
335 "SubscribersTableName= $subscribers_table "
336 "PermissionsBoundaryName= $BOUNDARY_NAME "
337 "StageName= $STAGE_NAME "
338 "MeteringLockHours= $METERING_LOCK_HOURS "
339 "UsageTableTtlDays= $USAGE_TABLE_TTL_DAYS "
340 "AggregatedUsageTableTtlDays= $AGGREGATED_USAGE_TTL_DAYS "
341 "CreateDashboard= $CREATE_DASHBOARD "
342 "DeploymentMode= $DEPLOYMENT_MODE "
343 "MeteringMode= $METERING_MODE "
344 "TestAccountAllowlist= $TEST_ACCOUNT_ALLOWLIST "
345 )
346 sam deploy \
347 --stack-name "${ prefix }-metering" \
348 --region " $region " \
349 --capabilities CAPABILITY_NAMED_IAM \
350 --s3-bucket " $main_bucket " \
351 --no-confirm-changeset \
352 --no-fail-on-empty-changeset \
353 --parameter-overrides "${ overrides [ @ ]}"
354
355 echo "✅ Main stack deployed"
356 echo ""
357 echo "Stack outputs:"
358 aws cloudformation describe-stacks \
359 --stack-name "${ prefix }-metering" \
360 --region " $region " \
361 --query 'Stacks[0].Outputs[*].[OutputKey,OutputValue]' \
362 --output table
363 }
364
365 # ─── Main ────────────────────────────────────────────────────────────────────
366 check_prerequisites
367
368 echo "=== Verifying AWS credentials ==="
369 ACCOUNT_ID = $( aws sts get-caller-identity --query Account --output text 2> /dev/null ) || {
370 echo "ERROR: AWS credentials not configured. Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or run 'aws configure'."
371 exit 1
372 }
373 echo "Account: $ACCOUNT_ID "
374
375 # CreateDashboard: whether to create the CloudWatch health dashboards (default true). Set
376 # CREATE_DASHBOARD=false to skip; sellers can also delete/customize post-deploy. Sourced
377 # before the events-only branch so both stacks honor it.
378 CREATE_DASHBOARD = "${ CREATE_DASHBOARD :- true }"
379 if [ " $CREATE_DASHBOARD " != "true" ] && [ " $CREATE_DASHBOARD " != "false" ]; then
380 echo "ERROR: invalid CREATE_DASHBOARD ' $CREATE_DASHBOARD ' (must be 'true' or 'false')."
381 exit 1
382 fi
383
384 # DEPLOYMENT_MODE selects full (raw pipeline: discoverer/aggregator/cleanup) or direct-submit
385 # (seller writes finalized records straight to aggregated_usage; only submitter + submission-
386 # expiry are created). Direct-submit records MUST be final before insert (any record may be
387 # submitted on the next 5-min run; a re-write returns DuplicateRecord).
388 DEPLOYMENT_MODE = "${ DEPLOYMENT_MODE :- full }"
389 if [ " $DEPLOYMENT_MODE " != "full" ] && [ " $DEPLOYMENT_MODE " != "direct-submit" ]; then
390 echo "ERROR: invalid DEPLOYMENT_MODE ' $DEPLOYMENT_MODE ' (must be 'full' or 'direct-submit')."
391 exit 1
392 fi
393
394 # Handle events-only mode
395 if [ " ${1 :- } " = "events-only" ]; then
396 # Events stack is stage-scoped too — require STAGE_NAME here as well.
397 STAGE_NAME = "${ STAGE_NAME :- }"
398 if [ -z " $STAGE_NAME " ] || ! [[ " $STAGE_NAME " =~ ^[A-Za-z0-9_-]+$ ]]; then
399 echo "ERROR: STAGE_NAME is required for events-only (e.g. STAGE_NAME=v1 ./deploy.sh events-only)."
400 exit 1
401 fi
402 EVENTS_PREFIX = "awsmp-events-${ STAGE_NAME }"
403 EVENTS_STACK_NAME = "awsmp-events-${ STAGE_NAME }-stack"
404 METERING_MODE = "${ METERING_MODE :- live }"
405 if [ " $METERING_MODE " != "live" ] && [ " $METERING_MODE " != "dry-run" ]; then
406 echo "ERROR: invalid METERING_MODE ' $METERING_MODE ' (allowed: live | dry-run)." ; exit 1
407 fi
408 TEST_ACCOUNT_ALLOWLIST = "${ TEST_ACCOUNT_ALLOWLIST :- }"
409 if [ " $METERING_MODE " = "dry-run" ]; then
410 if [ -z " $TEST_ACCOUNT_ALLOWLIST " ]; then
411 echo "ERROR: METERING_MODE=dry-run requires TEST_ACCOUNT_ALLOWLIST (test buyer account ids)." ; exit 1
412 fi
413 if ! [[ " $TEST_ACCOUNT_ALLOWLIST " =~ ^[0-9]{ 12 }( , [ 0 - 9 ]{ 12 }) * $ ]]; then
414 echo "ERROR: TEST_ACCOUNT_ALLOWLIST must be comma-separated 12-digit AWS account ids." ; exit 1
415 fi
416 fi
417 if [ " $METERING_MODE " = "live" ] && [ -n " $TEST_ACCOUNT_ALLOWLIST " ]; then
418 echo "ERROR: TEST_ACCOUNT_ALLOWLIST must be EMPTY for METERING_MODE=live." ; exit 1
419 fi
420 if [ " $METERING_MODE " = "dry-run" ]; then
421 EVENT_SOURCE = "${ STAGE_NAME }.agreement-marketplace"
422 case " $EVENT_SOURCE " in
423 aws. * ) echo "ERROR: dry-run EVENT_SOURCE must not start with 'aws.' (STAGE_NAME=' $STAGE_NAME ' collides with the reserved production source)." ; exit 1 ;;
424 esac
425 else
426 EVENT_SOURCE = "aws.agreement-marketplace"
427 fi
428 PRODUCT_CODE = "${ PRODUCT_CODE :- }"
429 deploy_events_stack
430 echo ""
431 echo "Next: Deploy main stack per-product:"
432 echo " STAGE_NAME= $STAGE_NAME ./deploy.sh <PRODUCT_CODE> <REGION>"
433 exit 0
434 fi
435
436 # Full deployment mode
437 PRODUCT_CODE = " ${1 :? Usage : . / deploy . sh events-only | . / deploy . sh < PRODUCT_CODE > < REGION > [PREFIX] } "
438 REGION = " ${2 :? Usage : . / deploy . sh < PRODUCT_CODE > < REGION > [PREFIX] } "
439
440 # StageName is a MANDATORY seller-supplied answer — there is no default and no fallback
441 # to `prod`. Supply it via the STAGE_NAME environment variable. Deployment fails fast if
442 # it is empty so we never create an API with an empty/placeholder stage.
443 STAGE_NAME = "${ STAGE_NAME :- }"
444 if [ -z " $STAGE_NAME " ]; then
445 echo "ERROR: STAGE_NAME is required and has no default."
446 echo " Set the API Gateway stage name you want (your choice — e.g. v1, live, or prod)."
447 echo " Example: STAGE_NAME=v1 ./deploy.sh $PRODUCT_CODE $REGION "
448 exit 1
449 fi
450 if ! [[ " $STAGE_NAME " =~ ^[A-Za-z0-9_-]+$ ]]; then
451 echo "ERROR: invalid STAGE_NAME ' $STAGE_NAME ' (allowed: A-Z a-z 0-9 _ -)."
452 exit 1
453 fi
454
455 # Stage-scoped names: the stage folds into BOTH stacks so multiple stages coexist without
456 # collision. Main stack: awsmp-<productCode>-<stage>-metering (prefix awsmp-<productCode>-<stage>).
457 # Events stack: awsmp-events-<stage>-stack (prefix awsmp-events-<stage>).
458 PREFIX = " ${3 :- awsmp- ${ PRODUCT_CODE } - ${ STAGE_NAME } } "
459 EVENTS_PREFIX = "awsmp-events-${ STAGE_NAME }"
460 EVENTS_STACK_NAME = "awsmp-events-${ STAGE_NAME }-stack"
461
462 # MeteringMode decides prod (live) vs non-prod (dry-run). live = real BatchMeterUsage;
463 # dry-run = full pipeline but NO real submit (a sandbox). Prod vs non-prod is THIS switch,
464 # not the free-form STAGE_NAME.
465 METERING_MODE = "${ METERING_MODE :- live }"
466 if [ " $METERING_MODE " != "live" ] && [ " $METERING_MODE " != "dry-run" ]; then
467 echo "ERROR: invalid METERING_MODE ' $METERING_MODE ' (allowed: live | dry-run)."
468 exit 1
469 fi
470 # For a non-prod (dry-run) stage a TestAccountAllowlist is REQUIRED and must be test accounts
471 # ONLY; a live stage must NOT set it. TEST_ACCOUNT_ALLOWLIST is comma-separated 12-digit ids.
472 TEST_ACCOUNT_ALLOWLIST = "${ TEST_ACCOUNT_ALLOWLIST :- }"
473 if [ " $METERING_MODE " = "dry-run" ]; then
474 if [ -z " $TEST_ACCOUNT_ALLOWLIST " ]; then
475 echo "ERROR: METERING_MODE=dry-run requires TEST_ACCOUNT_ALLOWLIST (comma-separated TEST"
476 echo " buyer account ids). A non-prod stage must use test accounts ONLY."
477 exit 1
478 fi
479 if ! [[ " $TEST_ACCOUNT_ALLOWLIST " =~ ^[0-9]{ 12 }( , [ 0 - 9 ]{ 12 }) * $ ]]; then
480 echo "ERROR: TEST_ACCOUNT_ALLOWLIST must be comma-separated 12-digit AWS account ids."
481 exit 1
482 fi
483 echo "NOTE: dry-run (non-prod) stage — the submitter will NOT call BatchMeterUsage (no real"
484 echo " billing); use TEST accounts ONLY: $TEST_ACCOUNT_ALLOWLIST "
485 else
486 if [ -n " $TEST_ACCOUNT_ALLOWLIST " ]; then
487 echo "ERROR: TEST_ACCOUNT_ALLOWLIST must be EMPTY for METERING_MODE=live (production)."
488 exit 1
489 fi
490 fi
491 # EventSource the events rule matches. dry-run uses a STAGE-SCOPED source (never the reserved
492 # aws.* prefix) so the test publisher's events are delivered AND are disjoint from real prod
493 # events (no same-account cross-match). live uses the real aws.agreement-marketplace.
494 if [ " $METERING_MODE " = "dry-run" ]; then
495 EVENT_SOURCE = "${ STAGE_NAME }.agreement-marketplace"
496 case " $EVENT_SOURCE " in
497 aws. * )
498 echo "ERROR: dry-run EVENT_SOURCE must not start with 'aws.' (STAGE_NAME=' $STAGE_NAME '"
499 echo " collides with the reserved production source aws.agreement-marketplace)."
500 exit 1 ;;
501 esac
502 else
503 EVENT_SOURCE = "aws.agreement-marketplace"
504 fi
505
506 # MeteringLockHours: how many hours a metering hour stays open for late usage
507 # before it is aggregated/submitted. Seller-configured via the METERING_LOCK_HOURS env var;
508 # default 1 (submit a fully-complete hour). Validated 1..20 so an hour is first submitted
509 # with >=4h of margin inside the 24h billable window (>=3 retries + rejection re-drive).
510 METERING_LOCK_HOURS = "${ METERING_LOCK_HOURS :- 1 }"
511 if ! [[ " $METERING_LOCK_HOURS " =~ ^[0-9]+$ ]] || [ " $METERING_LOCK_HOURS " -lt 1 ] || [ " $METERING_LOCK_HOURS " -gt 20 ]; then
512 echo "ERROR: invalid METERING_LOCK_HOURS ' $METERING_LOCK_HOURS ' (must be an integer 1..20)."
513 echo " This is how many hours an hour stays open for late usage before submission."
514 exit 1
515 fi
516
517 # TTL retention (days) for the RAW usage table (high-volume; RECOMMENDED on to control
518 # cost). 0 disables. Floor of 2 days so a row can never expire before it is metered
519 # (must exceed MeteringLockHours + the 24h billable window). The seller's WRITER sets the
520 # `ttl` attribute; enabling it here only makes DynamoDB honor it.
521 USAGE_TABLE_TTL_DAYS = "${ USAGE_TABLE_TTL_DAYS :- 365 }"
522 if ! [[ " $USAGE_TABLE_TTL_DAYS " =~ ^[0-9]+$ ]] || { [ " $USAGE_TABLE_TTL_DAYS " -ne 0 ] && [ " $USAGE_TABLE_TTL_DAYS " -lt 2 ]; } || [ " $USAGE_TABLE_TTL_DAYS " -gt 3650 ]; then
523 echo "ERROR: invalid USAGE_TABLE_TTL_DAYS ' $USAGE_TABLE_TTL_DAYS ' (0 to disable, else an integer 2..3650)."
524 exit 1
525 fi
526
527 # TTL retention (days) for the AGGREGATED usage table (small billing audit trail).
528 # DEFAULTS 0 (disabled = retain), which is recommended. When >0 the submitter sets `ttl`
529 # on finalized Success rows only.
530 AGGREGATED_USAGE_TTL_DAYS = "${ AGGREGATED_USAGE_TTL_DAYS :- 0 }"
531 if ! [[ " $AGGREGATED_USAGE_TTL_DAYS " =~ ^[0-9]+$ ]] || [ " $AGGREGATED_USAGE_TTL_DAYS " -gt 3650 ]; then
532 echo "ERROR: invalid AGGREGATED_USAGE_TTL_DAYS ' $AGGREGATED_USAGE_TTL_DAYS ' (0 to disable, else an integer 1..3650)."
533 exit 1
534 fi
535
536 # Validate all caller-supplied inputs before any AWS call.
537 validate_inputs " $PRODUCT_CODE " " $REGION " " $PREFIX "
538
539 echo "Product code: $PRODUCT_CODE "
540 echo "Region: $REGION "
541 echo "Stack prefix: $PREFIX "
542 echo "Stage name: $STAGE_NAME "
543
544 deploy_events_stack
545 deploy_main_stack " $PRODUCT_CODE " " $REGION " " $PREFIX "
546
547 echo ""
548 echo "=== Deployment complete ==="
549 echo ""
550 echo "Next steps:"
551 echo "1. Copy the RegistrationUrl from stack outputs above"
552 echo "2. Set it as the fulfillment URL in AWS Marketplace Management Portal"
553 echo "3. Subscribe to the product and click 'Set up your account' to test"