stackport 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. stackport-0.4.3/MANIFEST.in +2 -0
  2. {stackport-0.4.1/stackport.egg-info → stackport-0.4.3}/PKG-INFO +50 -9
  3. {stackport-0.4.1 → stackport-0.4.3}/README.md +48 -7
  4. {stackport-0.4.1 → stackport-0.4.3}/backend/cli.py +37 -35
  5. {stackport-0.4.1 → stackport-0.4.3}/backend/config.py +3 -0
  6. stackport-0.4.3/backend/learn/__init__.py +1 -0
  7. stackport-0.4.3/backend/learn/content.py +107 -0
  8. stackport-0.4.3/backend/learn/hotspots.py +27 -0
  9. stackport-0.4.3/backend/learn/trails/aws-first-steps.json +171 -0
  10. stackport-0.4.3/backend/learn/verify.py +172 -0
  11. stackport-0.4.3/backend/learn_store.py +130 -0
  12. {stackport-0.4.1 → stackport-0.4.3}/backend/main.py +10 -2
  13. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/dynamodb.py +55 -1
  14. stackport-0.4.3/backend/routes/learn.py +233 -0
  15. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/resources.py +1 -0
  16. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/s3.py +43 -0
  17. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/stats.py +2 -0
  18. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/dynamodb.py +22 -0
  19. stackport-0.4.3/backend/schemas/learn.py +44 -0
  20. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/s3.py +51 -1
  21. {stackport-0.4.1 → stackport-0.4.3}/pyproject.toml +3 -2
  22. {stackport-0.4.1 → stackport-0.4.3/stackport.egg-info}/PKG-INFO +50 -9
  23. {stackport-0.4.1 → stackport-0.4.3}/stackport.egg-info/SOURCES.txt +52 -36
  24. {stackport-0.4.1 → stackport-0.4.3}/tests/test_cli.py +65 -0
  25. stackport-0.4.3/tests/test_create_resources.py +238 -0
  26. stackport-0.4.3/tests/test_learn_content.py +241 -0
  27. stackport-0.4.3/tests/test_learn_routes.py +284 -0
  28. stackport-0.4.3/tests/test_learn_store.py +52 -0
  29. {stackport-0.4.1 → stackport-0.4.3}/tests/test_registries.py +17 -1
  30. stackport-0.4.3/ui/dist/assets/CloudscapeAbout-BRbsDskR.js +1 -0
  31. stackport-0.4.3/ui/dist/assets/CloudscapeDashboard-j2f4Elz2.js +1 -0
  32. stackport-0.4.3/ui/dist/assets/CloudscapeLearn-CVW3k3X0.js +1 -0
  33. stackport-0.4.3/ui/dist/assets/CloudscapeResourceBrowser-CgWdgn20.js +19 -0
  34. stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css → stackport-0.4.3/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +1 -1
  35. stackport-0.4.3/ui/dist/assets/CloudscapeSettings-CIXFvPKv.js +1 -0
  36. stackport-0.4.3/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +1 -0
  37. stackport-0.4.3/ui/dist/assets/CloudscapeShell-DNfhSg0O.js +195 -0
  38. stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js → stackport-0.4.3/ui/dist/assets/StateMachineGraph-DvF_kIOO.js +1 -1
  39. stackport-0.4.3/ui/dist/assets/index-BC7E-Fm4.js +1 -0
  40. stackport-0.4.3/ui/dist/assets/index-BLdRlH9d.js +67 -0
  41. stackport-0.4.3/ui/dist/assets/index-Ba-oOcd3.js +1 -0
  42. stackport-0.4.3/ui/dist/assets/index-BkSgVQ0X.js +1 -0
  43. stackport-0.4.3/ui/dist/assets/index-C5z3cir-.js +1 -0
  44. stackport-0.4.3/ui/dist/assets/index-DKIdXFX0.css +1 -0
  45. stackport-0.4.1/ui/dist/assets/index-DaXIyw62.css → stackport-0.4.3/ui/dist/assets/index-DNBdeKId.css +1 -1
  46. stackport-0.4.3/ui/dist/assets/index-DoY0HscW.css +1 -0
  47. stackport-0.4.3/ui/dist/assets/index-DrkkXAoG.css +1 -0
  48. stackport-0.4.3/ui/dist/assets/index-Dw6TGweb.js +1 -0
  49. stackport-0.4.3/ui/dist/assets/index-Vf0UFiCT.css +1 -0
  50. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/index.html +2 -2
  51. stackport-0.4.1/MANIFEST.in +0 -1
  52. stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +0 -1
  53. stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +0 -1
  54. stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +0 -19
  55. stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +0 -1
  56. stackport-0.4.1/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +0 -1
  57. stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +0 -201
  58. stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +0 -1
  59. stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +0 -1
  60. stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +0 -1
  61. stackport-0.4.1/ui/dist/assets/index-Cah5DqCf.css +0 -1
  62. stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +0 -52
  63. stackport-0.4.1/ui/dist/assets/index-DRZKHoZl.css +0 -1
  64. stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +0 -1
  65. stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +0 -1
  66. stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +0 -1
  67. {stackport-0.4.1 → stackport-0.4.3}/LICENSE +0 -0
  68. {stackport-0.4.1 → stackport-0.4.3}/backend/__init__.py +0 -0
  69. {stackport-0.4.1 → stackport-0.4.3}/backend/aws_client.py +0 -0
  70. {stackport-0.4.1 → stackport-0.4.3}/backend/cache.py +0 -0
  71. {stackport-0.4.1 → stackport-0.4.3}/backend/endpoint_store.py +0 -0
  72. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/__init__.py +0 -0
  73. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/common.py +0 -0
  74. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/ec2.py +0 -0
  75. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/endpoints.py +0 -0
  76. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/iam.py +0 -0
  77. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/lambda_svc.py +0 -0
  78. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/logs.py +0 -0
  79. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/monitoring.py +0 -0
  80. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/rds.py +0 -0
  81. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/secretsmanager.py +0 -0
  82. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/sns.py +0 -0
  83. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/sqs.py +0 -0
  84. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/stepfunctions.py +0 -0
  85. {stackport-0.4.1 → stackport-0.4.3}/backend/routes/tags.py +0 -0
  86. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/__init__.py +0 -0
  87. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/endpoints.py +0 -0
  88. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/lambda_svc.py +0 -0
  89. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/logs.py +0 -0
  90. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/monitoring.py +0 -0
  91. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/secretsmanager.py +0 -0
  92. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/sns.py +0 -0
  93. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/sqs.py +0 -0
  94. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/stepfunctions.py +0 -0
  95. {stackport-0.4.1 → stackport-0.4.3}/backend/schemas/tags.py +0 -0
  96. {stackport-0.4.1 → stackport-0.4.3}/backend/websocket.py +0 -0
  97. {stackport-0.4.1 → stackport-0.4.3}/setup.cfg +0 -0
  98. {stackport-0.4.1 → stackport-0.4.3}/stackport.egg-info/dependency_links.txt +0 -0
  99. {stackport-0.4.1 → stackport-0.4.3}/stackport.egg-info/entry_points.txt +0 -0
  100. {stackport-0.4.1 → stackport-0.4.3}/stackport.egg-info/requires.txt +0 -0
  101. {stackport-0.4.1 → stackport-0.4.3}/stackport.egg-info/top_level.txt +0 -0
  102. {stackport-0.4.1 → stackport-0.4.3}/tests/test_cache.py +0 -0
  103. {stackport-0.4.1 → stackport-0.4.3}/tests/test_client.py +0 -0
  104. {stackport-0.4.1 → stackport-0.4.3}/tests/test_config.py +0 -0
  105. {stackport-0.4.1 → stackport-0.4.3}/tests/test_dynamodb_routes.py +0 -0
  106. {stackport-0.4.1 → stackport-0.4.3}/tests/test_ec2_routes.py +0 -0
  107. {stackport-0.4.1 → stackport-0.4.3}/tests/test_endpoint_store.py +0 -0
  108. {stackport-0.4.1 → stackport-0.4.3}/tests/test_endpoints.py +0 -0
  109. {stackport-0.4.1 → stackport-0.4.3}/tests/test_iam_routes.py +0 -0
  110. {stackport-0.4.1 → stackport-0.4.3}/tests/test_lambda_routes.py +0 -0
  111. {stackport-0.4.1 → stackport-0.4.3}/tests/test_logs_routes.py +0 -0
  112. {stackport-0.4.1 → stackport-0.4.3}/tests/test_logs_tail.py +0 -0
  113. {stackport-0.4.1 → stackport-0.4.3}/tests/test_monitoring_routes.py +0 -0
  114. {stackport-0.4.1 → stackport-0.4.3}/tests/test_multi_endpoint.py +0 -0
  115. {stackport-0.4.1 → stackport-0.4.3}/tests/test_rds_routes.py +0 -0
  116. {stackport-0.4.1 → stackport-0.4.3}/tests/test_readonly_middleware.py +0 -0
  117. {stackport-0.4.1 → stackport-0.4.3}/tests/test_routes.py +0 -0
  118. {stackport-0.4.1 → stackport-0.4.3}/tests/test_s3_routes.py +0 -0
  119. {stackport-0.4.1 → stackport-0.4.3}/tests/test_s3_upload_limit_env.py +0 -0
  120. {stackport-0.4.1 → stackport-0.4.3}/tests/test_secretsmanager_routes.py +0 -0
  121. {stackport-0.4.1 → stackport-0.4.3}/tests/test_sns_routes.py +0 -0
  122. {stackport-0.4.1 → stackport-0.4.3}/tests/test_sqs_routes.py +0 -0
  123. {stackport-0.4.1 → stackport-0.4.3}/tests/test_tags_routes.py +0 -0
  124. {stackport-0.4.1 → stackport-0.4.3}/tests/test_websocket.py +0 -0
  125. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/assets/index-DE--A2EE.css +0 -0
  126. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  127. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
  128. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/acm.svg +0 -0
  129. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/apigateway.svg +0 -0
  130. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/appsync.svg +0 -0
  131. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/athena.svg +0 -0
  132. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/cloudformation.svg +0 -0
  133. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/cloudfront.svg +0 -0
  134. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  135. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/dynamodb.svg +0 -0
  136. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/ec2.svg +0 -0
  137. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/ecr.svg +0 -0
  138. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/ecs.svg +0 -0
  139. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/elasticache.svg +0 -0
  140. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  141. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  142. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  143. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/events.svg +0 -0
  144. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/firehose.svg +0 -0
  145. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/glue.svg +0 -0
  146. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/iam.svg +0 -0
  147. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/kinesis.svg +0 -0
  148. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/kms.svg +0 -0
  149. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/lambda.svg +0 -0
  150. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/logs.svg +0 -0
  151. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/monitoring.svg +0 -0
  152. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/rds.svg +0 -0
  153. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/route53.svg +0 -0
  154. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/s3.svg +0 -0
  155. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  156. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/ses.svg +0 -0
  157. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/sns.svg +0 -0
  158. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/sqs.svg +0 -0
  159. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/ssm.svg +0 -0
  160. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  161. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/aws-icons/wafv2.svg +0 -0
  162. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/favicon.png +0 -0
  163. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/favicon.svg +0 -0
  164. {stackport-0.4.1 → stackport-0.4.3}/ui/dist/stackport-logo.svg +0 -0
@@ -0,0 +1,2 @@
1
+ recursive-include ui/dist *
2
+ recursive-include backend/learn/trails *.json
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/DaviReisVieira/stackport
8
8
  Project-URL: Repository, https://github.com/DaviReisVieira/stackport
9
9
  Project-URL: Issues, https://github.com/DaviReisVieira/stackport/issues
10
- Keywords: aws,ministack,localstack,browser,devtools,emulator
10
+ Keywords: aws,ministack,floci,localstack,browser,devtools,emulator
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Framework :: FastAPI
13
13
  Classifier: Intended Audience :: Developers
@@ -70,19 +70,23 @@ Dynamic: license-file
70
70
  - **Write operations** — upload/delete S3 objects, query DynamoDB, invoke Lambda, send/receive SQS messages
71
71
  - **Real AWS support** — connect to real AWS accounts with read-only mode by default
72
72
  - **Per-endpoint authentication** — default credentials, AWS profiles (SSO/AssumeRole), or static keys per endpoint
73
+ - **Guided lessons** — hands-on AWS tutorials that run inside the console and verify your real resource state
73
74
  - **Tag management** — unified tagging across 21 resource types
74
75
  - **CLI** — `stackport status`, `list`, `describe`, `export` with JSON/CSV/table output
75
76
  - **Real-time dashboard** with WebSocket-powered live updates
76
77
  - **Keyboard shortcuts** — 16 shortcuts for fast navigation (press `?` to view)
77
- - Single Docker image, works with MiniStack, LocalStack, Moto, or any AWS-compatible endpoint
78
+ - Single Docker image, works with [MiniStack](https://github.com/ministackorg/ministack), [Floci](https://github.com/floci-io/floci), LocalStack, Moto, or any AWS-compatible endpoint
78
79
 
79
80
  ## Quick Start
80
81
 
81
- ### With a local emulator (recommended)
82
+ ### With a local emulator
83
+
84
+ Start whichever emulator you use, then point StackPort at it:
82
85
 
83
86
  ```bash
84
- # Start MiniStack (or LocalStack, Moto, etc.)
85
- pip install ministack && ministack
87
+ # Start an emulator on :4566 (pick one)
88
+ docker run -d -p 4566:4566 ministackorg/ministack # MiniStack
89
+ docker run -d -p 4566:4566 floci/floci # Floci
86
90
 
87
91
  # Start StackPort
88
92
  pip install stackport
@@ -107,16 +111,32 @@ You can also configure per-endpoint authentication from the Settings UI — sele
107
111
 
108
112
  When connected to real AWS, StackPort shows a warning banner and operates in read-only mode unless writes are explicitly enabled.
109
113
 
110
- ### Docker Compose (MiniStack + StackPort)
114
+ ### Docker Compose
115
+
116
+ Each example runs the emulator, StackPort, and a one-shot `seed` container that creates a few resources (S3, SQS, SNS, DynamoDB, Secrets Manager, IAM, Logs, Step Functions) so the dashboard has something to show on first load. Both use the same seed. Swap in LocalStack, Moto, or any AWS-compatible endpoint by replacing the emulator service and updating `AWS_ENDPOINT_URL`.
111
117
 
112
- This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
118
+ **MiniStack + StackPort** — [`examples/docker-compose.yml`](examples/docker-compose.yml)
113
119
 
114
120
  ```bash
115
121
  curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
116
122
  docker compose up -d
117
123
  # Open http://localhost:8080
124
+ # If the dashboard is empty: docker compose logs seed
118
125
  ```
119
126
 
127
+ [MiniStack](https://github.com/ministackorg/ministack) derives the URLs it returns (SQS queue URLs, pre-signed URLs) from the request `Host`, so they resolve from other containers and from the host without extra configuration.
128
+
129
+ **Floci + StackPort** — [`examples/docker-compose.floci.yml`](examples/docker-compose.floci.yml)
130
+
131
+ ```bash
132
+ curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.floci.yml
133
+ docker compose -f docker-compose.floci.yml up -d
134
+ # Open http://localhost:8080
135
+ # If the dashboard is empty: docker compose -f docker-compose.floci.yml logs seed
136
+ ```
137
+
138
+ [Floci](https://github.com/floci-io/floci) takes the hostname for the URLs it returns from `FLOCI_HOSTNAME`, which the example sets to `floci` so they resolve from other containers. From the host, reach the same resources through `http://localhost:4566`.
139
+
120
140
  ### Docker (standalone)
121
141
 
122
142
  ```bash
@@ -206,6 +226,21 @@ You can test connections before saving using the "Test Connection" button in the
206
226
 
207
227
  All other services use a searchable resource table with JSON detail view, pagination, and export (JSON/CSV).
208
228
 
229
+ ## Learn
230
+
231
+ Guided lessons that run inside StackPort itself, against your own emulator. A panel walks you through a lesson step by step while hotspots point at the actual controls, and StackPort checks the live resource state, so a step only turns green when the thing genuinely exists.
232
+
233
+ Open **Learn** in the sidebar (or press `g l`) and start a lesson. Every step gives you two ways through it:
234
+
235
+ - **Do it here** — the popover points at the control to use, with the exact values to type, copyable.
236
+ - **With the CLI** — the command, already filled in with your endpoint and your resource names, plus an explanation of what each flag does.
237
+
238
+ Either way, verification is the same: StackPort calls the real API and reports what it found. Do the work in a terminal and the step turns green on its own within a few seconds, no clicking required. Nothing is a gate, either: any step can be skipped and re-checked later.
239
+
240
+ You pick the names. A lesson generates something like `stackport-learn-k3xz` and every command and field on screen uses it, so the commands can be copied and run without editing. Change it under "Names used in this lesson" and everything follows.
241
+
242
+ Lessons only run against a local emulator, since they create and delete resources. Progress is stored locally in `~/.stackport/learn_progress.json`. Set `STACKPORT_LEARN=false` to remove Learn entirely.
243
+
209
244
  ## Tag Management
210
245
 
211
246
  Unified tag read/write across 21 resource types:
@@ -244,6 +279,7 @@ Press `?` anywhere to see all shortcuts.
244
279
  | `b` | Toggle sidebar |
245
280
  | `g d` | Go to Dashboard |
246
281
  | `g r` | Go to Resources |
282
+ | `g l` | Go to Learn |
247
283
  | `/` | Focus search |
248
284
  | `j` / `k` | Navigate up/down in resource list |
249
285
  | `[` / `]` | Previous/next service |
@@ -263,12 +299,13 @@ Press `?` anywhere to see all shortcuts.
263
299
  | `STACKPORT_PORT` | `8080` | StackPort server port |
264
300
  | `STACKPORT_ALLOW_WRITES` | `true` | Enable write operations (POST/PUT/DELETE) |
265
301
  | `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max S3 upload size per object (MiB) |
302
+ | `STACKPORT_LEARN` | `true` | Guided tutorials. `false` removes the Learn routes and UI |
266
303
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated list of services to probe |
267
304
  | `STACKPORT_PROBE_TIMEOUT` | `5` | Seconds before a service probe times out |
268
305
  | `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
269
306
  | `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
270
307
  | `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
271
- | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
308
+ | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json, learn_progress.json) |
272
309
  | `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
273
310
 
274
311
  ## Supported Services (35)
@@ -333,6 +370,10 @@ ui/src/
333
370
  lib/ API client, types, service icons, utils
334
371
  ```
335
372
 
373
+ ## Origin
374
+
375
+ StackPort started as a web UI pull request to [MiniStack](https://github.com/ministackorg/ministack) ([ministack#116](https://github.com/ministackorg/ministack/pull/116)). The MiniStack maintainers suggested it would be more useful as a standalone tool that talks to any emulator over the standard AWS API, and that is what it became. Thanks to [@Nahuel990](https://github.com/Nahuel990) and [@McDoit](https://github.com/McDoit) for pushing it in that direction.
376
+
336
377
  ## Contributors
337
378
 
338
379
  <a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
@@ -34,19 +34,23 @@
34
34
  - **Write operations** — upload/delete S3 objects, query DynamoDB, invoke Lambda, send/receive SQS messages
35
35
  - **Real AWS support** — connect to real AWS accounts with read-only mode by default
36
36
  - **Per-endpoint authentication** — default credentials, AWS profiles (SSO/AssumeRole), or static keys per endpoint
37
+ - **Guided lessons** — hands-on AWS tutorials that run inside the console and verify your real resource state
37
38
  - **Tag management** — unified tagging across 21 resource types
38
39
  - **CLI** — `stackport status`, `list`, `describe`, `export` with JSON/CSV/table output
39
40
  - **Real-time dashboard** with WebSocket-powered live updates
40
41
  - **Keyboard shortcuts** — 16 shortcuts for fast navigation (press `?` to view)
41
- - Single Docker image, works with MiniStack, LocalStack, Moto, or any AWS-compatible endpoint
42
+ - Single Docker image, works with [MiniStack](https://github.com/ministackorg/ministack), [Floci](https://github.com/floci-io/floci), LocalStack, Moto, or any AWS-compatible endpoint
42
43
 
43
44
  ## Quick Start
44
45
 
45
- ### With a local emulator (recommended)
46
+ ### With a local emulator
47
+
48
+ Start whichever emulator you use, then point StackPort at it:
46
49
 
47
50
  ```bash
48
- # Start MiniStack (or LocalStack, Moto, etc.)
49
- pip install ministack && ministack
51
+ # Start an emulator on :4566 (pick one)
52
+ docker run -d -p 4566:4566 ministackorg/ministack # MiniStack
53
+ docker run -d -p 4566:4566 floci/floci # Floci
50
54
 
51
55
  # Start StackPort
52
56
  pip install stackport
@@ -71,16 +75,32 @@ You can also configure per-endpoint authentication from the Settings UI — sele
71
75
 
72
76
  When connected to real AWS, StackPort shows a warning banner and operates in read-only mode unless writes are explicitly enabled.
73
77
 
74
- ### Docker Compose (MiniStack + StackPort)
78
+ ### Docker Compose
79
+
80
+ Each example runs the emulator, StackPort, and a one-shot `seed` container that creates a few resources (S3, SQS, SNS, DynamoDB, Secrets Manager, IAM, Logs, Step Functions) so the dashboard has something to show on first load. Both use the same seed. Swap in LocalStack, Moto, or any AWS-compatible endpoint by replacing the emulator service and updating `AWS_ENDPOINT_URL`.
75
81
 
76
- This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
82
+ **MiniStack + StackPort** — [`examples/docker-compose.yml`](examples/docker-compose.yml)
77
83
 
78
84
  ```bash
79
85
  curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
80
86
  docker compose up -d
81
87
  # Open http://localhost:8080
88
+ # If the dashboard is empty: docker compose logs seed
82
89
  ```
83
90
 
91
+ [MiniStack](https://github.com/ministackorg/ministack) derives the URLs it returns (SQS queue URLs, pre-signed URLs) from the request `Host`, so they resolve from other containers and from the host without extra configuration.
92
+
93
+ **Floci + StackPort** — [`examples/docker-compose.floci.yml`](examples/docker-compose.floci.yml)
94
+
95
+ ```bash
96
+ curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.floci.yml
97
+ docker compose -f docker-compose.floci.yml up -d
98
+ # Open http://localhost:8080
99
+ # If the dashboard is empty: docker compose -f docker-compose.floci.yml logs seed
100
+ ```
101
+
102
+ [Floci](https://github.com/floci-io/floci) takes the hostname for the URLs it returns from `FLOCI_HOSTNAME`, which the example sets to `floci` so they resolve from other containers. From the host, reach the same resources through `http://localhost:4566`.
103
+
84
104
  ### Docker (standalone)
85
105
 
86
106
  ```bash
@@ -170,6 +190,21 @@ You can test connections before saving using the "Test Connection" button in the
170
190
 
171
191
  All other services use a searchable resource table with JSON detail view, pagination, and export (JSON/CSV).
172
192
 
193
+ ## Learn
194
+
195
+ Guided lessons that run inside StackPort itself, against your own emulator. A panel walks you through a lesson step by step while hotspots point at the actual controls, and StackPort checks the live resource state, so a step only turns green when the thing genuinely exists.
196
+
197
+ Open **Learn** in the sidebar (or press `g l`) and start a lesson. Every step gives you two ways through it:
198
+
199
+ - **Do it here** — the popover points at the control to use, with the exact values to type, copyable.
200
+ - **With the CLI** — the command, already filled in with your endpoint and your resource names, plus an explanation of what each flag does.
201
+
202
+ Either way, verification is the same: StackPort calls the real API and reports what it found. Do the work in a terminal and the step turns green on its own within a few seconds, no clicking required. Nothing is a gate, either: any step can be skipped and re-checked later.
203
+
204
+ You pick the names. A lesson generates something like `stackport-learn-k3xz` and every command and field on screen uses it, so the commands can be copied and run without editing. Change it under "Names used in this lesson" and everything follows.
205
+
206
+ Lessons only run against a local emulator, since they create and delete resources. Progress is stored locally in `~/.stackport/learn_progress.json`. Set `STACKPORT_LEARN=false` to remove Learn entirely.
207
+
173
208
  ## Tag Management
174
209
 
175
210
  Unified tag read/write across 21 resource types:
@@ -208,6 +243,7 @@ Press `?` anywhere to see all shortcuts.
208
243
  | `b` | Toggle sidebar |
209
244
  | `g d` | Go to Dashboard |
210
245
  | `g r` | Go to Resources |
246
+ | `g l` | Go to Learn |
211
247
  | `/` | Focus search |
212
248
  | `j` / `k` | Navigate up/down in resource list |
213
249
  | `[` / `]` | Previous/next service |
@@ -227,12 +263,13 @@ Press `?` anywhere to see all shortcuts.
227
263
  | `STACKPORT_PORT` | `8080` | StackPort server port |
228
264
  | `STACKPORT_ALLOW_WRITES` | `true` | Enable write operations (POST/PUT/DELETE) |
229
265
  | `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max S3 upload size per object (MiB) |
266
+ | `STACKPORT_LEARN` | `true` | Guided tutorials. `false` removes the Learn routes and UI |
230
267
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated list of services to probe |
231
268
  | `STACKPORT_PROBE_TIMEOUT` | `5` | Seconds before a service probe times out |
232
269
  | `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
233
270
  | `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
234
271
  | `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
235
- | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
272
+ | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json, learn_progress.json) |
236
273
  | `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
237
274
 
238
275
  ## Supported Services (35)
@@ -297,6 +334,10 @@ ui/src/
297
334
  lib/ API client, types, service icons, utils
298
335
  ```
299
336
 
337
+ ## Origin
338
+
339
+ StackPort started as a web UI pull request to [MiniStack](https://github.com/ministackorg/ministack) ([ministack#116](https://github.com/ministackorg/ministack/pull/116)). The MiniStack maintainers suggested it would be more useful as a standalone tool that talks to any emulator over the standard AWS API, and that is what it became. Thanks to [@Nahuel990](https://github.com/Nahuel990) and [@McDoit](https://github.com/McDoit) for pushing it in that direction.
340
+
300
341
  ## Contributors
301
342
 
302
343
  <a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
@@ -10,13 +10,37 @@ import click
10
10
  import uvicorn
11
11
 
12
12
  from backend.aws_client import get_client
13
- from backend.config import AWS_ENDPOINT_URL, AWS_REGION, LOG_LEVEL, STACKPORT_PORT, STACKPORT_SERVICES
14
- from backend.routes.resources import DESCRIBE_REGISTRY, _extract_id, _serialize
13
+ from backend.config import (
14
+ AWS_ENDPOINT_URL,
15
+ AWS_REGION,
16
+ LOG_LEVEL,
17
+ STACKPORT_PORT,
18
+ STACKPORT_SERVICES,
19
+ endpoint_store,
20
+ )
21
+ from backend.routes.resources import (
22
+ DESCRIBE_REGISTRY,
23
+ _PREFERRED_ID_FIELD,
24
+ _extract_id,
25
+ _serialize,
26
+ )
15
27
  from backend.routes.stats import SERVICE_REGISTRY, _METHOD_KWARGS, _count_items, _probe_service
16
28
 
17
29
  logger = logging.getLogger(__name__)
18
30
 
19
31
 
32
+ def _client_kwargs(endpoint: str | None, region: str | None) -> dict:
33
+ """Resolve where a CLI command talks to.
34
+
35
+ An explicit --endpoint (or AWS_ENDPOINT_URL at startup) wins. Without one,
36
+ fall back to the saved default endpoint, the same one the web UI uses.
37
+ Passing the result to get_client explicitly is what makes the flag work
38
+ once ~/.stackport/endpoints.json exists.
39
+ """
40
+ url = endpoint if endpoint is not None else endpoint_store.get_default_url()
41
+ return {"endpoint_url": url, "region": region}
42
+
43
+
20
44
  @click.group(invoke_without_command=True)
21
45
  @click.version_option(package_name="stackport")
22
46
  @click.pass_context
@@ -39,22 +63,14 @@ def serve(port):
39
63
  @click.option("--output", type=click.Choice(["json", "table"]), default="table", help="Output format")
40
64
  def status(endpoint, region, output):
41
65
  """Show all services with availability and resource counts."""
42
- # Override endpoint/region if provided
43
- if endpoint != AWS_ENDPOINT_URL:
44
- import os
45
-
46
- os.environ["AWS_ENDPOINT_URL"] = endpoint
47
- if region != AWS_REGION:
48
- import os
49
-
50
- os.environ["AWS_REGION"] = region
66
+ client_kwargs = _client_kwargs(endpoint, region)
51
67
 
52
68
  enabled_services = [s.strip() for s in STACKPORT_SERVICES.split(",") if s.strip()]
53
69
  services = {}
54
70
 
55
71
  try:
56
72
  with ThreadPoolExecutor(max_workers=min(len(enabled_services), 10)) as executor:
57
- futures = {executor.submit(_probe_service, svc): svc for svc in enabled_services}
73
+ futures = {executor.submit(_probe_service, svc, **client_kwargs): svc for svc in enabled_services}
58
74
  for future in as_completed(futures):
59
75
  svc_name, result = future.result()
60
76
  services[svc_name] = result
@@ -89,15 +105,7 @@ def status(endpoint, region, output):
89
105
  @click.option("--output", type=click.Choice(["json", "table", "csv"]), default="table", help="Output format")
90
106
  def list(service, endpoint, region, output):
91
107
  """List resources for a service."""
92
- # Override endpoint/region if provided
93
- if endpoint != AWS_ENDPOINT_URL:
94
- import os
95
-
96
- os.environ["AWS_ENDPOINT_URL"] = endpoint
97
- if region != AWS_REGION:
98
- import os
99
-
100
- os.environ["AWS_REGION"] = region
108
+ client_kwargs = _client_kwargs(endpoint, region)
101
109
 
102
110
  registry_entries = SERVICE_REGISTRY.get(service)
103
111
  if not registry_entries:
@@ -109,7 +117,7 @@ def list(service, endpoint, region, output):
109
117
  resources = {}
110
118
  for resource_type, boto3_service, method_name, response_key in registry_entries:
111
119
  try:
112
- client = get_client(boto3_service)
120
+ client = get_client(boto3_service, **client_kwargs)
113
121
  method = getattr(client, method_name)
114
122
  kwargs = _METHOD_KWARGS.get((boto3_service, method_name), {})
115
123
  resp = method(**kwargs)
@@ -129,8 +137,9 @@ def list(service, endpoint, region, output):
129
137
  writer = csv.writer(sys.stdout)
130
138
  writer.writerow(["service", "resource_type", "resource_id", "name"])
131
139
  for resource_type, items in resources.items():
140
+ preferred = _PREFERRED_ID_FIELD.get((service, resource_type))
132
141
  for item in items:
133
- resource_id = _extract_id(item)
142
+ resource_id = _extract_id(item, preferred)
134
143
  name = item.get("Name", item.get("FunctionName", item.get("TableName", resource_id))) if isinstance(item, dict) else resource_id
135
144
  writer.writerow([service, resource_type, resource_id, name])
136
145
  else:
@@ -139,8 +148,9 @@ def list(service, endpoint, region, output):
139
148
  if items:
140
149
  click.echo(f"\n{resource_type.upper()} ({len(items)}):")
141
150
  click.echo("-" * 60)
151
+ preferred = _PREFERRED_ID_FIELD.get((service, resource_type))
142
152
  for item in items[:20]: # Limit to first 20 for readability
143
- resource_id = _extract_id(item)
153
+ resource_id = _extract_id(item, preferred)
144
154
  if isinstance(item, dict):
145
155
  name = item.get("Name", item.get("FunctionName", item.get("TableName", "")))
146
156
  if name and name != resource_id:
@@ -164,20 +174,12 @@ def list(service, endpoint, region, output):
164
174
  @click.option("--output", type=click.Choice(["json", "table"]), default="json", help="Output format")
165
175
  def describe(service, resource_type, resource_id, endpoint, region, output):
166
176
  """Describe a specific resource."""
167
- # Override endpoint/region if provided
168
- if endpoint != AWS_ENDPOINT_URL:
169
- import os
170
-
171
- os.environ["AWS_ENDPOINT_URL"] = endpoint
172
- if region != AWS_REGION:
173
- import os
174
-
175
- os.environ["AWS_REGION"] = region
177
+ client_kwargs = _client_kwargs(endpoint, region)
176
178
 
177
179
  # Special case for WAFv2
178
180
  if (service, resource_type) == ("wafv2", "web_acls"):
179
181
  try:
180
- client = get_client("wafv2")
182
+ client = get_client("wafv2", **client_kwargs)
181
183
  acls = client.list_web_acls(Scope="REGIONAL").get("WebACLs", [])
182
184
  match = next((a for a in acls if a.get("Name") == resource_id), None)
183
185
  if not match:
@@ -211,7 +213,7 @@ def describe(service, resource_type, resource_id, endpoint, region, output):
211
213
  }
212
214
 
213
215
  try:
214
- client = get_client(boto3_service)
216
+ client = get_client(boto3_service, **client_kwargs)
215
217
  method = getattr(client, method_name)
216
218
  if id_param in _LIST_PARAMS:
217
219
  resp = method(**{id_param: [resource_id]})
@@ -16,6 +16,9 @@ AWS_ACCESS_KEY_ID: str | None = os.environ.get("AWS_ACCESS_KEY_ID")
16
16
  AWS_SECRET_ACCESS_KEY: str | None = os.environ.get("AWS_SECRET_ACCESS_KEY")
17
17
  STACKPORT_ALLOW_WRITES: bool = os.environ.get("STACKPORT_ALLOW_WRITES", "true").lower() in ("1", "true", "yes")
18
18
  STACKPORT_PORT: int = int(os.environ.get("STACKPORT_PORT", "8080"))
19
+ # Guided tutorials. Set to false to drop the Learn routes and every trace of
20
+ # the tutorial UI, for anyone who wants StackPort as a resource browser only.
21
+ STACKPORT_LEARN: bool = os.environ.get("STACKPORT_LEARN", "true").lower() in ("1", "true", "yes")
19
22
  STACKPORT_SERVICES: str = os.environ.get(
20
23
  "STACKPORT_SERVICES",
21
24
  "s3,sqs,sns,dynamodb,lambda,iam,logs,ssm,secretsmanager,kinesis,events,ec2,"
@@ -0,0 +1 @@
1
+ """Learn module: guided AWS trails verified against live emulator state."""
@@ -0,0 +1,107 @@
1
+ """Validation for authored trail content.
2
+
3
+ A typo in a trail JSON file, a verification type that does not exist or a step
4
+ pointing at a hotspot the UI never renders would all strand a learner mid
5
+ lesson with no way forward. Content is therefore validated when it loads, and
6
+ the test suite runs the same validation over everything that ships, so bad
7
+ content fails in CI instead of in front of someone trying to learn.
8
+ """
9
+
10
+ import re
11
+
12
+ from backend.learn.hotspots import KNOWN_HOTSPOT_IDS
13
+ from backend.learn.verify import CHECKS
14
+
15
+ _PLACEHOLDER_RE = re.compile(r"\{\{(\w+)\}\}")
16
+
17
+ # Filled in by the server rather than declared by a lesson
18
+ _IMPLICIT_VARIABLES = frozenset({"endpointUrl"})
19
+
20
+ _TRAIL_KEYS = ("id", "title", "description", "lessons")
21
+ _LESSON_KEYS = ("id", "title", "summary", "steps")
22
+ _STEP_KEYS = ("id", "title", "instruction")
23
+
24
+
25
+ def _require(data: dict, keys: tuple[str, ...], where: str) -> None:
26
+ missing = [key for key in keys if key not in data]
27
+ if missing:
28
+ raise ValueError(f"{where} is missing {', '.join(missing)}")
29
+
30
+
31
+ def _validate_verify(spec: dict, where: str) -> None:
32
+ check_type = spec.get("type")
33
+ if check_type not in CHECKS:
34
+ raise ValueError(f"{where} uses unknown verify type '{check_type}'")
35
+ if check_type == "all_of":
36
+ for nested in spec.get("params", {}).get("checks", []):
37
+ _validate_verify(nested, f"{where} (nested)")
38
+
39
+
40
+ def validate_trail(trail: dict) -> None:
41
+ """Raise ValueError describing the first problem found, if any."""
42
+ _require(trail, _TRAIL_KEYS, "trail")
43
+ if not trail["lessons"]:
44
+ raise ValueError(f"trail '{trail['id']}' has no lessons")
45
+
46
+ lesson_ids: set[str] = set()
47
+ for lesson in trail["lessons"]:
48
+ _require(lesson, _LESSON_KEYS, f"lesson in trail '{trail['id']}'")
49
+ where_lesson = f"{trail['id']}/{lesson['id']}"
50
+ if lesson["id"] in lesson_ids:
51
+ raise ValueError(f"duplicate lesson id '{lesson['id']}' in trail '{trail['id']}'")
52
+ lesson_ids.add(lesson["id"])
53
+
54
+ declared = {spec["name"] for spec in lesson.get("variables", [])} | _IMPLICIT_VARIABLES
55
+ for spec in lesson.get("variables", []):
56
+ if "default" not in spec:
57
+ raise ValueError(f"variable '{spec['name']}' in {where_lesson} has no default")
58
+
59
+ if not lesson["steps"]:
60
+ raise ValueError(f"lesson {where_lesson} has no steps")
61
+
62
+ # everything except the variable specs, whose defaults carry {{suffix}}
63
+ body = {key: value for key, value in lesson.items() if key != "variables"}
64
+ for name in _placeholders(body) - declared:
65
+ raise ValueError(f"lesson {where_lesson} uses undeclared variable '{{{{{name}}}}}'")
66
+
67
+ step_ids: set[str] = set()
68
+ hotspot_ids: set[str] = set()
69
+ for step in lesson["steps"]:
70
+ _require(step, _STEP_KEYS, f"step in lesson {where_lesson}")
71
+ where = f"{where_lesson}/{step['id']}"
72
+ if step["id"] in step_ids:
73
+ raise ValueError(f"duplicate step id '{step['id']}' in lesson {where_lesson}")
74
+ step_ids.add(step["id"])
75
+
76
+ if step.get("verify"):
77
+ _validate_verify(step["verify"], where)
78
+
79
+ console = step.get("console")
80
+ if console:
81
+ hotspot_id = console.get("hotspotId")
82
+ if hotspot_id and hotspot_id not in KNOWN_HOTSPOT_IDS:
83
+ raise ValueError(f"{where} points at unknown hotspot '{hotspot_id}'")
84
+ if hotspot_id in hotspot_ids:
85
+ # two steps sharing an anchor would unlock the second one's
86
+ # Next button as soon as the first one mounted
87
+ raise ValueError(f"{where} reuses hotspot '{hotspot_id}' within the same lesson")
88
+ if hotspot_id:
89
+ hotspot_ids.add(hotspot_id)
90
+ if not console.get("route"):
91
+ raise ValueError(f"{where} has a console path with no route")
92
+
93
+ for name in _placeholders(step) - declared:
94
+ raise ValueError(f"{where} uses undeclared variable '{{{{{name}}}}}'")
95
+
96
+
97
+ def _placeholders(value: object) -> set[str]:
98
+ found: set[str] = set()
99
+ if isinstance(value, str):
100
+ found.update(_PLACEHOLDER_RE.findall(value))
101
+ elif isinstance(value, dict):
102
+ for nested in value.values():
103
+ found |= _placeholders(nested)
104
+ elif isinstance(value, list):
105
+ for nested in value:
106
+ found |= _placeholders(nested)
107
+ return found
@@ -0,0 +1,27 @@
1
+ """Hotspot ids a lesson step may point at.
2
+
3
+ A hotspot is an anchor rendered by the frontend next to a real control, which
4
+ the tutorial popover attaches to. The ids live here because trail content is
5
+ validated on the backend, and are mirrored in
6
+ ui/src/components/cloudscape/learn/hotspots.ts, where the anchors are actually
7
+ rendered. A frontend test reads this file and fails if the two lists drift, so
8
+ keep it plain data: one string literal per line, no logic.
9
+
10
+ Naming: {scope}-{noun}-{action}.
11
+ """
12
+
13
+ KNOWN_HOTSPOT_IDS: frozenset[str] = frozenset(
14
+ {
15
+ # shell
16
+ "learn-panel-anchor",
17
+ "nav-resources",
18
+ # s3
19
+ "s3-buckets-create",
20
+ "s3-buckets-table",
21
+ "s3-upload",
22
+ "s3-objects-table",
23
+ # dynamodb
24
+ "dynamodb-tables-create",
25
+ "dynamodb-tables-table",
26
+ }
27
+ )