stackport 0.4.7__tar.gz → 0.4.9__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 (179) hide show
  1. {stackport-0.4.7/stackport.egg-info → stackport-0.4.9}/PKG-INFO +6 -5
  2. {stackport-0.4.7 → stackport-0.4.9}/README.md +3 -2
  3. {stackport-0.4.7 → stackport-0.4.9}/backend/aws_client.py +10 -0
  4. {stackport-0.4.7 → stackport-0.4.9}/backend/cli.py +59 -81
  5. {stackport-0.4.7 → stackport-0.4.9}/backend/config.py +9 -2
  6. {stackport-0.4.7 → stackport-0.4.9}/backend/connection.py +28 -7
  7. stackport-0.4.9/backend/downloads.py +64 -0
  8. stackport-0.4.9/backend/learn/cli_setup.py +114 -0
  9. {stackport-0.4.7 → stackport-0.4.9}/backend/learn/verify.py +2 -7
  10. {stackport-0.4.7 → stackport-0.4.9}/backend/main.py +16 -2
  11. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/common.py +22 -20
  12. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/dynamodb.py +184 -171
  13. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/endpoints.py +3 -2
  14. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/iam.py +17 -15
  15. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/lambda_svc.py +8 -35
  16. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/learn.py +12 -0
  17. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/logs.py +27 -30
  18. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/resources.py +123 -96
  19. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/s3.py +49 -14
  20. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/sqs.py +48 -31
  21. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/stats.py +51 -11
  22. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/tags.py +51 -20
  23. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/sqs.py +35 -23
  24. {stackport-0.4.7 → stackport-0.4.9}/backend/websocket.py +127 -61
  25. {stackport-0.4.7 → stackport-0.4.9}/pyproject.toml +3 -3
  26. {stackport-0.4.7 → stackport-0.4.9/stackport.egg-info}/PKG-INFO +6 -5
  27. {stackport-0.4.7 → stackport-0.4.9}/stackport.egg-info/SOURCES.txt +34 -26
  28. {stackport-0.4.7 → stackport-0.4.9}/stackport.egg-info/requires.txt +2 -2
  29. stackport-0.4.9/tests/test_aws_client_checksums.py +77 -0
  30. {stackport-0.4.7 → stackport-0.4.9}/tests/test_aws_client_tls.py +43 -0
  31. stackport-0.4.9/tests/test_cli.py +370 -0
  32. {stackport-0.4.7 → stackport-0.4.9}/tests/test_connection.py +95 -0
  33. {stackport-0.4.7 → stackport-0.4.9}/tests/test_dynamodb_routes.py +281 -9
  34. {stackport-0.4.7 → stackport-0.4.9}/tests/test_endpoint_store_env.py +10 -0
  35. stackport-0.4.9/tests/test_generic_resources.py +514 -0
  36. {stackport-0.4.7 → stackport-0.4.9}/tests/test_iam_routes.py +100 -181
  37. stackport-0.4.9/tests/test_learn_cli_setup.py +153 -0
  38. {stackport-0.4.7 → stackport-0.4.9}/tests/test_logs_routes.py +87 -2
  39. {stackport-0.4.7 → stackport-0.4.9}/tests/test_logs_tail.py +2 -1
  40. {stackport-0.4.7 → stackport-0.4.9}/tests/test_multi_endpoint.py +12 -8
  41. {stackport-0.4.7 → stackport-0.4.9}/tests/test_registries.py +32 -1
  42. {stackport-0.4.7 → stackport-0.4.9}/tests/test_request_safety.py +32 -0
  43. {stackport-0.4.7 → stackport-0.4.9}/tests/test_s3_routes.py +137 -152
  44. stackport-0.4.9/tests/test_s3_transfers.py +285 -0
  45. {stackport-0.4.7 → stackport-0.4.9}/tests/test_sqs_routes.py +162 -0
  46. stackport-0.4.9/tests/test_suite_isolation.py +41 -0
  47. stackport-0.4.9/tests/test_tags_roundtrip.py +409 -0
  48. stackport-0.4.9/tests/test_websocket.py +409 -0
  49. stackport-0.4.7/ui/dist/assets/CloudscapeAbout-C464xiLP.js → stackport-0.4.9/ui/dist/assets/CloudscapeAbout-C4S2ahmA.js +1 -1
  50. stackport-0.4.7/ui/dist/assets/CloudscapeDashboard--Puj8I6B.js → stackport-0.4.9/ui/dist/assets/CloudscapeDashboard-CgPMF-G_.js +1 -1
  51. stackport-0.4.7/ui/dist/assets/CloudscapeLearn-CT7ucW_g.js → stackport-0.4.9/ui/dist/assets/CloudscapeLearn-B8fMSiDF.js +1 -1
  52. stackport-0.4.9/ui/dist/assets/CloudscapeResourceBrowser-IhkIJ1Hg.js +19 -0
  53. stackport-0.4.7/ui/dist/assets/CloudscapeSettings-D_9KUPvP.js → stackport-0.4.9/ui/dist/assets/CloudscapeSettings-D4ZhF-Od.js +1 -1
  54. stackport-0.4.7/ui/dist/assets/CloudscapeShell-BCyqqtsn.js → stackport-0.4.9/ui/dist/assets/CloudscapeShell-C7gsElS6.js +1 -1
  55. stackport-0.4.7/ui/dist/assets/ServiceStatus-KR6X2qeu.js → stackport-0.4.9/ui/dist/assets/ServiceStatus-Bn8D3Whz.js +1 -1
  56. stackport-0.4.7/ui/dist/assets/StateMachineGraph-D9IP3035.js → stackport-0.4.9/ui/dist/assets/StateMachineGraph-DBZaV-ye.js +1 -1
  57. stackport-0.4.7/ui/dist/assets/index-CHdSsqYw.js → stackport-0.4.9/ui/dist/assets/index-BIP6RNbD.js +1 -1
  58. stackport-0.4.7/ui/dist/assets/index-WR8pyfQO.js → stackport-0.4.9/ui/dist/assets/index-BOjNdz1a.js +1 -1
  59. stackport-0.4.7/ui/dist/assets/index-WNsJzp13.js → stackport-0.4.9/ui/dist/assets/index-C56KgCLT.js +1 -1
  60. stackport-0.4.7/ui/dist/assets/index-DKnYLurL.js → stackport-0.4.9/ui/dist/assets/index-R-ZHGMoF.js +1 -1
  61. stackport-0.4.9/ui/dist/assets/index-bfBTZg8t.js +67 -0
  62. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/index.html +1 -1
  63. stackport-0.4.7/tests/test_cli.py +0 -211
  64. stackport-0.4.7/tests/test_websocket.py +0 -58
  65. stackport-0.4.7/ui/dist/assets/CloudscapeResourceBrowser-CprDqhcQ.js +0 -19
  66. stackport-0.4.7/ui/dist/assets/index-CvtAnA9F.js +0 -67
  67. {stackport-0.4.7 → stackport-0.4.9}/LICENSE +0 -0
  68. {stackport-0.4.7 → stackport-0.4.9}/MANIFEST.in +0 -0
  69. {stackport-0.4.7 → stackport-0.4.9}/backend/__init__.py +0 -0
  70. {stackport-0.4.7 → stackport-0.4.9}/backend/aws_emulator.py +0 -0
  71. {stackport-0.4.7 → stackport-0.4.9}/backend/aws_errors.py +0 -0
  72. {stackport-0.4.7 → stackport-0.4.9}/backend/cache.py +0 -0
  73. {stackport-0.4.7 → stackport-0.4.9}/backend/emulators.py +0 -0
  74. {stackport-0.4.7 → stackport-0.4.9}/backend/endpoint_store.py +0 -0
  75. {stackport-0.4.7 → stackport-0.4.9}/backend/error_handlers.py +0 -0
  76. {stackport-0.4.7 → stackport-0.4.9}/backend/learn/__init__.py +0 -0
  77. {stackport-0.4.7 → stackport-0.4.9}/backend/learn/content.py +0 -0
  78. {stackport-0.4.7 → stackport-0.4.9}/backend/learn/hotspots.py +0 -0
  79. {stackport-0.4.7 → stackport-0.4.9}/backend/learn/trails/aws-first-steps.json +0 -0
  80. {stackport-0.4.7 → stackport-0.4.9}/backend/learn_store.py +0 -0
  81. {stackport-0.4.7 → stackport-0.4.9}/backend/origins.py +0 -0
  82. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/__init__.py +0 -0
  83. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/ec2.py +0 -0
  84. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/kms.py +0 -0
  85. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/monitoring.py +0 -0
  86. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/rds.py +0 -0
  87. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/secretsmanager.py +0 -0
  88. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/sns.py +0 -0
  89. {stackport-0.4.7 → stackport-0.4.9}/backend/routes/stepfunctions.py +0 -0
  90. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/__init__.py +0 -0
  91. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/dynamodb.py +0 -0
  92. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/endpoints.py +0 -0
  93. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/lambda_svc.py +0 -0
  94. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/learn.py +0 -0
  95. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/logs.py +0 -0
  96. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/monitoring.py +0 -0
  97. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/s3.py +0 -0
  98. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/secretsmanager.py +0 -0
  99. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/sns.py +0 -0
  100. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/stepfunctions.py +0 -0
  101. {stackport-0.4.7 → stackport-0.4.9}/backend/schemas/tags.py +0 -0
  102. {stackport-0.4.7 → stackport-0.4.9}/setup.cfg +0 -0
  103. {stackport-0.4.7 → stackport-0.4.9}/stackport.egg-info/dependency_links.txt +0 -0
  104. {stackport-0.4.7 → stackport-0.4.9}/stackport.egg-info/entry_points.txt +0 -0
  105. {stackport-0.4.7 → stackport-0.4.9}/stackport.egg-info/top_level.txt +0 -0
  106. {stackport-0.4.7 → stackport-0.4.9}/tests/test_aws_emulator.py +0 -0
  107. {stackport-0.4.7 → stackport-0.4.9}/tests/test_aws_errors.py +0 -0
  108. {stackport-0.4.7 → stackport-0.4.9}/tests/test_cache.py +0 -0
  109. {stackport-0.4.7 → stackport-0.4.9}/tests/test_client.py +0 -0
  110. {stackport-0.4.7 → stackport-0.4.9}/tests/test_config.py +0 -0
  111. {stackport-0.4.7 → stackport-0.4.9}/tests/test_create_resources.py +0 -0
  112. {stackport-0.4.7 → stackport-0.4.9}/tests/test_ec2_routes.py +0 -0
  113. {stackport-0.4.7 → stackport-0.4.9}/tests/test_emulators.py +0 -0
  114. {stackport-0.4.7 → stackport-0.4.9}/tests/test_endpoint_store.py +0 -0
  115. {stackport-0.4.7 → stackport-0.4.9}/tests/test_endpoints.py +0 -0
  116. {stackport-0.4.7 → stackport-0.4.9}/tests/test_endpoints_tls.py +0 -0
  117. {stackport-0.4.7 → stackport-0.4.9}/tests/test_error_handlers.py +0 -0
  118. {stackport-0.4.7 → stackport-0.4.9}/tests/test_kms_routes.py +0 -0
  119. {stackport-0.4.7 → stackport-0.4.9}/tests/test_lambda_code_download.py +0 -0
  120. {stackport-0.4.7 → stackport-0.4.9}/tests/test_lambda_routes.py +0 -0
  121. {stackport-0.4.7 → stackport-0.4.9}/tests/test_learn_content.py +0 -0
  122. {stackport-0.4.7 → stackport-0.4.9}/tests/test_learn_routes.py +0 -0
  123. {stackport-0.4.7 → stackport-0.4.9}/tests/test_learn_store.py +0 -0
  124. {stackport-0.4.7 → stackport-0.4.9}/tests/test_monitoring_routes.py +0 -0
  125. {stackport-0.4.7 → stackport-0.4.9}/tests/test_rds_routes.py +0 -0
  126. {stackport-0.4.7 → stackport-0.4.9}/tests/test_readonly_middleware.py +0 -0
  127. {stackport-0.4.7 → stackport-0.4.9}/tests/test_routes.py +0 -0
  128. {stackport-0.4.7 → stackport-0.4.9}/tests/test_s3_upload_limit_env.py +0 -0
  129. {stackport-0.4.7 → stackport-0.4.9}/tests/test_secretsmanager_routes.py +0 -0
  130. {stackport-0.4.7 → stackport-0.4.9}/tests/test_sns_routes.py +0 -0
  131. {stackport-0.4.7 → stackport-0.4.9}/tests/test_stats_probe.py +0 -0
  132. {stackport-0.4.7 → stackport-0.4.9}/tests/test_tags_routes.py +0 -0
  133. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/CloudscapeResourceBrowser-BR6m7Ajg.css +0 -0
  134. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/CloudscapeSettings-DI0pOcAA.css +0 -0
  135. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/CloudscapeShell-oIMuVycJ.css +0 -0
  136. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/ServiceStatus-DNBdeKId.css +0 -0
  137. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/index-DKIdXFX0.css +0 -0
  138. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/index-D__irddE.css +0 -0
  139. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/index-DoY0HscW.css +0 -0
  140. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/index-RwoKZF4y.css +0 -0
  141. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
  142. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/assets/utils-Dm4tJbJR.js +0 -0
  143. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/acm.svg +0 -0
  144. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/apigateway.svg +0 -0
  145. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/appsync.svg +0 -0
  146. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/athena.svg +0 -0
  147. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/cloudformation.svg +0 -0
  148. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/cloudfront.svg +0 -0
  149. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  150. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/dynamodb.svg +0 -0
  151. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/ec2.svg +0 -0
  152. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/ecr.svg +0 -0
  153. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/ecs.svg +0 -0
  154. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/elasticache.svg +0 -0
  155. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  156. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  157. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  158. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/events.svg +0 -0
  159. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/firehose.svg +0 -0
  160. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/glue.svg +0 -0
  161. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/iam.svg +0 -0
  162. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/kinesis.svg +0 -0
  163. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/kms.svg +0 -0
  164. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/lambda.svg +0 -0
  165. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/logs.svg +0 -0
  166. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/monitoring.svg +0 -0
  167. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/rds.svg +0 -0
  168. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/route53.svg +0 -0
  169. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/s3.svg +0 -0
  170. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  171. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/ses.svg +0 -0
  172. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/sns.svg +0 -0
  173. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/sqs.svg +0 -0
  174. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/ssm.svg +0 -0
  175. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  176. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/aws-icons/wafv2.svg +0 -0
  177. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/favicon.png +0 -0
  178. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/favicon.svg +0 -0
  179. {stackport-0.4.7 → stackport-0.4.9}/ui/dist/stackport-logo.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: fastapi>=0.115.0
25
25
  Requires-Dist: uvicorn>=0.30.0
26
- Requires-Dist: boto3>=1.35.0
26
+ Requires-Dist: boto3>=1.36.0
27
27
  Requires-Dist: click>=8.0
28
28
  Requires-Dist: websockets>=12.0
29
29
  Requires-Dist: python-multipart>=0.0.26
@@ -31,7 +31,7 @@ Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8.0; extra == "dev"
32
32
  Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
33
33
  Requires-Dist: httpx>=0.27.0; extra == "dev"
34
- Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
34
+ Requires-Dist: moto[apigateway,apigatewayv2,cognitoidp,dynamodb,glue,iam,lambda,s3,sqs]>=5.0; extra == "dev"
35
35
  Dynamic: license-file
36
36
 
37
37
  <p align="center">
@@ -249,7 +249,7 @@ You can test a connection before saving it with the "Test Connection" button in
249
249
  | Service | Browse | Write operations |
250
250
  |---------|--------|------------------|
251
251
  | **S3** | Buckets, objects, folder navigation, search | Create buckets and folders, upload, download, delete, batch delete; edit versioning, lifecycle, notifications, CORS, tags |
252
- | **DynamoDB** | Tables, schema, items, scan and query | Create tables, add, edit and delete items, batch write |
252
+ | **DynamoDB** | Tables, schema, items, scan and query, live item count | Create tables, add, edit and delete items, batch write |
253
253
  | **Lambda** | Functions, config, aliases, versions, event sources | Invoke with a JSON payload, edit configuration, download code |
254
254
  | **SQS** | Queues, messages, attributes | Create and delete queues, send (single or batch), receive, delete messages, purge, edit attributes and redrive policy |
255
255
  | **SNS** | Topics, subscriptions | Create and delete topics, subscribe, unsubscribe, publish |
@@ -314,7 +314,7 @@ stackport export lambda --format json
314
314
  stackport export ec2 --format csv
315
315
  ```
316
316
 
317
- `status`, `list`, `describe`, and `export` accept `--endpoint URL` and `--region REGION`. Without them they use `AWS_ENDPOINT_URL` and `AWS_REGION`, or the default endpoint saved in Settings.
317
+ `status`, `list`, `describe`, and `export` accept `--endpoint` (a saved endpoint name or a URL) and `--region REGION`. Without `--endpoint` they use `AWS_ENDPOINT_URL` or the default endpoint saved in Settings. A saved endpoint brings its profile or keys, region and TLS settings (CA bundle, verification off) with it, and `--region` overrides its region. A URL passed directly uses no saved settings.
318
318
 
319
319
  ## Keyboard shortcuts
320
320
 
@@ -341,6 +341,7 @@ Press `?` anywhere to see all shortcuts.
341
341
  | `AWS_REGION` | `us-east-1` | AWS region |
342
342
  | `AWS_ACCESS_KEY_ID` | *(unset)* | AWS access key. Unset = use credential chain |
343
343
  | `AWS_SECRET_ACCESS_KEY` | *(unset)* | AWS secret key. Unset = use credential chain |
344
+ | `AWS_SESSION_TOKEN` | *(unset)* | Session token for temporary credentials (SSO exports, assumed roles). Sent only with the two keys above. Read at start-up, so a new token needs a restart |
344
345
  | `AWS_PROFILE` | *(unset)* | AWS named profile from `~/.aws/config` |
345
346
  | `STACKPORT_PORT` | `8080` | StackPort server port |
346
347
  | `STACKPORT_HOST` | `127.0.0.1` | Interface StackPort listens on (`0.0.0.0` in the Docker image). StackPort has no login, so open it to the network only where that's fine |
@@ -213,7 +213,7 @@ You can test a connection before saving it with the "Test Connection" button in
213
213
  | Service | Browse | Write operations |
214
214
  |---------|--------|------------------|
215
215
  | **S3** | Buckets, objects, folder navigation, search | Create buckets and folders, upload, download, delete, batch delete; edit versioning, lifecycle, notifications, CORS, tags |
216
- | **DynamoDB** | Tables, schema, items, scan and query | Create tables, add, edit and delete items, batch write |
216
+ | **DynamoDB** | Tables, schema, items, scan and query, live item count | Create tables, add, edit and delete items, batch write |
217
217
  | **Lambda** | Functions, config, aliases, versions, event sources | Invoke with a JSON payload, edit configuration, download code |
218
218
  | **SQS** | Queues, messages, attributes | Create and delete queues, send (single or batch), receive, delete messages, purge, edit attributes and redrive policy |
219
219
  | **SNS** | Topics, subscriptions | Create and delete topics, subscribe, unsubscribe, publish |
@@ -278,7 +278,7 @@ stackport export lambda --format json
278
278
  stackport export ec2 --format csv
279
279
  ```
280
280
 
281
- `status`, `list`, `describe`, and `export` accept `--endpoint URL` and `--region REGION`. Without them they use `AWS_ENDPOINT_URL` and `AWS_REGION`, or the default endpoint saved in Settings.
281
+ `status`, `list`, `describe`, and `export` accept `--endpoint` (a saved endpoint name or a URL) and `--region REGION`. Without `--endpoint` they use `AWS_ENDPOINT_URL` or the default endpoint saved in Settings. A saved endpoint brings its profile or keys, region and TLS settings (CA bundle, verification off) with it, and `--region` overrides its region. A URL passed directly uses no saved settings.
282
282
 
283
283
  ## Keyboard shortcuts
284
284
 
@@ -305,6 +305,7 @@ Press `?` anywhere to see all shortcuts.
305
305
  | `AWS_REGION` | `us-east-1` | AWS region |
306
306
  | `AWS_ACCESS_KEY_ID` | *(unset)* | AWS access key. Unset = use credential chain |
307
307
  | `AWS_SECRET_ACCESS_KEY` | *(unset)* | AWS secret key. Unset = use credential chain |
308
+ | `AWS_SESSION_TOKEN` | *(unset)* | Session token for temporary credentials (SSO exports, assumed roles). Sent only with the two keys above. Read at start-up, so a new token needs a restart |
308
309
  | `AWS_PROFILE` | *(unset)* | AWS named profile from `~/.aws/config` |
309
310
  | `STACKPORT_PORT` | `8080` | StackPort server port |
310
311
  | `STACKPORT_HOST` | `127.0.0.1` | Interface StackPort listens on (`0.0.0.0` in the Docker image). StackPort has no login, so open it to the network only where that's fine |
@@ -26,10 +26,18 @@ _UNSET = object()
26
26
  # dozens of services. Locally nothing is throttled, a refused connection or a
27
27
  # rejected certificate won't fix itself on retry, and the UI polls again in
28
28
  # seconds, so fail fast instead. STACKPORT_PROBE_TIMEOUT bounds the connect.
29
+ # - Flexible checksums (CRC32 on uploads, checked on downloads) only where an API
30
+ # requires them, as before botocore 1.36. Emulators keep them unevenly: moto
31
+ # returns a multipart object's checksum of part checksums as if it covered the
32
+ # whole object, so a download of it failed its check. Without asking for them,
33
+ # StackPort's multipart uploads don't leave such objects behind for other
34
+ # clients either.
29
35
  _LOCAL_ENDPOINT_CONFIG = Config(
30
36
  inject_host_prefix=False,
31
37
  connect_timeout=STACKPORT_PROBE_TIMEOUT,
32
38
  retries={"mode": "standard", "total_max_attempts": 1},
39
+ request_checksum_calculation="when_required",
40
+ response_checksum_validation="when_required",
33
41
  )
34
42
 
35
43
  # Last stat stamp seen per AWS Emulator files directory, to notice restarts.
@@ -47,6 +55,8 @@ def _build_client(service_name: str, plan: ConnectionPlan):
47
55
  session_kwargs["aws_access_key_id"] = plan.access_key_id
48
56
  if plan.secret_access_key is not None:
49
57
  session_kwargs["aws_secret_access_key"] = plan.secret_access_key
58
+ if plan.session_token is not None:
59
+ session_kwargs["aws_session_token"] = plan.session_token
50
60
  session = boto3.Session(**session_kwargs)
51
61
 
52
62
  client_kwargs: dict = {"service_name": service_name}
@@ -12,34 +12,64 @@ import uvicorn
12
12
  from backend.aws_client import get_client
13
13
  from backend.config import (
14
14
  AWS_ENDPOINT_URL,
15
- AWS_REGION,
15
+ ENDPOINTS,
16
16
  LOG_LEVEL,
17
17
  STACKPORT_HOST,
18
18
  STACKPORT_PORT,
19
19
  STACKPORT_SERVICES,
20
20
  endpoint_store,
21
21
  )
22
+ from backend.connection import EndpointInfo, InvalidEndpointUrl
23
+ from backend.routes.common import UnknownEndpoint, endpoint_info_for
22
24
  from backend.routes.resources import (
23
- DESCRIBE_REGISTRY,
24
25
  _PREFERRED_ID_FIELD,
26
+ UnknownResourceType,
25
27
  _extract_id,
26
28
  _serialize,
29
+ describe_resource,
27
30
  )
28
- from backend.routes.stats import SERVICE_REGISTRY, _METHOD_KWARGS, _count_items, _probe_service
31
+ from backend.routes.stats import SERVICE_REGISTRY, _probe_service, list_items
29
32
 
30
33
  logger = logging.getLogger(__name__)
31
34
 
32
35
 
36
+ def _startup_endpoint_info() -> EndpointInfo:
37
+ """Connection info for AWS_ENDPOINT_URL, with the settings of the env endpoint it defines.
38
+
39
+ The store points that env endpoint (normally "default") at the environment's URL
40
+ for the run. If it was deleted, the URL is used on its own.
41
+ """
42
+ name = next((n for n, url in ENDPOINTS.items() if url == AWS_ENDPOINT_URL), None)
43
+ entry = endpoint_store.get(name) if name is not None else None
44
+ if entry is not None and entry["source"] == "env" and entry["url"] == AWS_ENDPOINT_URL:
45
+ return EndpointInfo.from_entry(entry)
46
+ return endpoint_info_for(AWS_ENDPOINT_URL)
47
+
48
+
33
49
  def _client_kwargs(endpoint: str | None, region: str | None) -> dict:
34
- """Resolve where a CLI command talks to.
50
+ """Resolve where a CLI command talks to, the way the web app does.
51
+
52
+ ``endpoint`` is a saved endpoint's name or a URL. Without it, AWS_ENDPOINT_URL
53
+ at startup wins (with the settings of the env endpoint that defines it), then
54
+ the saved default endpoint. A saved endpoint brings its auth and TLS settings
55
+ along; an explicit ``region`` overrides its region. Passing the result to
56
+ get_client explicitly is what makes the flag work once
57
+ ~/.stackport/endpoints.json exists.
35
58
 
36
- An explicit --endpoint (or AWS_ENDPOINT_URL at startup) wins. Without one,
37
- fall back to the saved default endpoint, the same one the web UI uses.
38
- Passing the result to get_client explicitly is what makes the flag work
39
- once ~/.stackport/endpoints.json exists.
59
+ Raises:
60
+ click.BadParameter: the name matches no saved endpoint, or the URL is refused.
40
61
  """
41
- url = endpoint if endpoint is not None else endpoint_store.get_default_url()
42
- return {"endpoint_url": url, "region": region}
62
+ try:
63
+ if endpoint is None and AWS_ENDPOINT_URL is not None:
64
+ info = _startup_endpoint_info()
65
+ else:
66
+ info = endpoint_info_for(endpoint, strict=True)
67
+ except (InvalidEndpointUrl, UnknownEndpoint) as exc:
68
+ raise click.BadParameter(str(exc), param_hint="--endpoint") from exc
69
+ kwargs = info.client_kwargs()
70
+ if region is not None:
71
+ kwargs["region"] = region
72
+ return kwargs
43
73
 
44
74
 
45
75
  @click.group(invoke_without_command=True)
@@ -60,8 +90,8 @@ def serve(port, host):
60
90
 
61
91
 
62
92
  @cli.command()
63
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
64
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
93
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
94
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
65
95
  @click.option("--output", type=click.Choice(["json", "table"]), default="table", help="Output format")
66
96
  def status(endpoint, region, output):
67
97
  """Show all services with availability and resource counts."""
@@ -102,8 +132,8 @@ def status(endpoint, region, output):
102
132
 
103
133
  @cli.command()
104
134
  @click.argument("service")
105
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
106
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
135
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
136
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
107
137
  @click.option("--output", type=click.Choice(["json", "table", "csv"]), default="table", help="Output format")
108
138
  def list(service, endpoint, region, output):
109
139
  """List resources for a service."""
@@ -120,14 +150,7 @@ def list(service, endpoint, region, output):
120
150
  for resource_type, boto3_service, method_name, response_key in registry_entries:
121
151
  try:
122
152
  client = get_client(boto3_service, **client_kwargs)
123
- method = getattr(client, method_name)
124
- kwargs = _METHOD_KWARGS.get((boto3_service, method_name), {})
125
- resp = method(**kwargs)
126
- items = resp.get(response_key, [])
127
- # Handle nested structures (e.g., cloudfront DistributionList.Items)
128
- if isinstance(items, dict) and "Items" in items:
129
- items = items.get("Items", []) or []
130
- resources[resource_type] = items
153
+ resources[resource_type] = list_items(client, boto3_service, method_name, response_key)
131
154
  except Exception as exc:
132
155
  logger.debug("Failed to list %s/%s: %s", service, resource_type, exc, exc_info=True)
133
156
  resources[resource_type] = []
@@ -171,68 +194,23 @@ def list(service, endpoint, region, output):
171
194
  @click.argument("service")
172
195
  @click.argument("resource_type")
173
196
  @click.argument("resource_id")
174
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
175
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
197
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
198
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
176
199
  @click.option("--output", type=click.Choice(["json", "table"]), default="json", help="Output format")
177
200
  def describe(service, resource_type, resource_id, endpoint, region, output):
178
201
  """Describe a specific resource."""
179
202
  client_kwargs = _client_kwargs(endpoint, region)
180
203
 
181
- # Special case for WAFv2
182
- if (service, resource_type) == ("wafv2", "web_acls"):
183
- try:
184
- client = get_client("wafv2", **client_kwargs)
185
- acls = client.list_web_acls(Scope="REGIONAL").get("WebACLs", [])
186
- match = next((a for a in acls if a.get("Name") == resource_id), None)
187
- if not match:
188
- click.echo(f"Error: Web ACL '{resource_id}' not found", err=True)
189
- sys.exit(1)
190
- resp = client.get_web_acl(Name=resource_id, Scope="REGIONAL", Id=match["Id"])
191
- resp.pop("ResponseMetadata", None)
192
- detail = _serialize(resp.get("WebACL", resp))
193
- except Exception as exc:
194
- click.echo(f"Error describing {service}/{resource_type}/{resource_id}: {exc}", err=True)
195
- sys.exit(1)
196
- else:
197
- lookup = DESCRIBE_REGISTRY.get((service, resource_type))
198
- if not lookup:
199
- click.echo(f"Error: No detail lookup registered for {service}/{resource_type}", err=True)
200
- sys.exit(1)
201
-
202
- boto3_service, method_name, id_param, response_key = lookup
203
-
204
- # Some APIs take list parameters
205
- _LIST_PARAMS = {
206
- "InstanceIds",
207
- "VpcIds",
208
- "SubnetIds",
209
- "GroupIds",
210
- "VolumeIds",
211
- "repositoryNames",
212
- "clusters",
213
- "AlarmNames",
214
- "LoadBalancerArns",
215
- }
216
-
217
- try:
218
- client = get_client(boto3_service, **client_kwargs)
219
- method = getattr(client, method_name)
220
- if id_param in _LIST_PARAMS:
221
- resp = method(**{id_param: [resource_id]})
222
- else:
223
- resp = method(**{id_param: resource_id})
224
-
225
- resp.pop("ResponseMetadata", None)
226
-
227
- if response_key is not None:
228
- detail = resp.get(response_key, resp)
229
- else:
230
- detail = resp
231
-
232
- detail = _serialize(detail)
233
- except Exception as exc:
234
- click.echo(f"Error describing {service}/{resource_type}/{resource_id}: {exc}", err=True)
235
- sys.exit(1)
204
+ try:
205
+ detail = describe_resource(
206
+ lambda boto3_service: get_client(boto3_service, **client_kwargs), service, resource_type, resource_id
207
+ )
208
+ except UnknownResourceType as exc:
209
+ click.echo(f"Error: {exc}", err=True)
210
+ sys.exit(1)
211
+ except Exception as exc:
212
+ click.echo(f"Error describing {service}/{resource_type}/{resource_id}: {exc}", err=True)
213
+ sys.exit(1)
236
214
 
237
215
  if output == "json":
238
216
  click.echo(json.dumps(detail, indent=2))
@@ -253,8 +231,8 @@ def describe(service, resource_type, resource_id, endpoint, region, output):
253
231
 
254
232
  @cli.command()
255
233
  @click.argument("service")
256
- @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
257
- @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
234
+ @click.option("--endpoint", default=None, help="Saved endpoint name or endpoint URL (default: AWS_ENDPOINT_URL if set, else the saved default endpoint)")
235
+ @click.option("--region", default=None, help="AWS region (default: the endpoint's region, then AWS_REGION)")
258
236
  @click.option("--format", "format_", type=click.Choice(["json", "csv"]), default="json", help="Output format")
259
237
  @click.pass_context
260
238
  def export(ctx, service, endpoint, region, format_):
@@ -10,6 +10,11 @@ def _parse_aws_endpoint_url(environ: Mapping[str, str]) -> str | None:
10
10
  return environ.get("AWS_ENDPOINT_URL") or None
11
11
 
12
12
 
13
+ def _parse_aws_credential(environ: Mapping[str, str], name: str) -> str | None:
14
+ """An access key, secret key or session token. Empty counts as unset (``AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}`` in Compose)."""
15
+ return environ.get(name, "").strip() or None
16
+
17
+
13
18
  AWS_ENDPOINT_URL: str | None = _parse_aws_endpoint_url(os.environ)
14
19
 
15
20
  # Remove AWS_ENDPOINT_URL from the process environment so boto3's internal
@@ -17,8 +22,10 @@ AWS_ENDPOINT_URL: str | None = _parse_aws_endpoint_url(os.environ)
17
22
  if "AWS_ENDPOINT_URL" in os.environ:
18
23
  del os.environ["AWS_ENDPOINT_URL"]
19
24
  AWS_REGION: str = os.environ.get("AWS_REGION", "us-east-1")
20
- AWS_ACCESS_KEY_ID: str | None = os.environ.get("AWS_ACCESS_KEY_ID")
21
- AWS_SECRET_ACCESS_KEY: str | None = os.environ.get("AWS_SECRET_ACCESS_KEY")
25
+ AWS_ACCESS_KEY_ID: str | None = _parse_aws_credential(os.environ, "AWS_ACCESS_KEY_ID")
26
+ AWS_SECRET_ACCESS_KEY: str | None = _parse_aws_credential(os.environ, "AWS_SECRET_ACCESS_KEY")
27
+ # Belongs to the two keys above (SSO exports, assumed roles). Read once at start-up.
28
+ AWS_SESSION_TOKEN: str | None = _parse_aws_credential(os.environ, "AWS_SESSION_TOKEN")
22
29
  STACKPORT_ALLOW_WRITES: bool = os.environ.get("STACKPORT_ALLOW_WRITES", "true").lower() in ("1", "true", "yes")
23
30
  STACKPORT_PORT: int = int(os.environ.get("STACKPORT_PORT", "8080"))
24
31
  # Interface the server listens on. Loopback by default: StackPort has no login
@@ -8,7 +8,9 @@ Credentials, most specific first:
8
8
 
9
9
  1. the endpoint's own auth (a profile, or an access key pair);
10
10
  2. AWS Emulator's ``emulator_credentials`` for this endpoint, when present;
11
- 3. ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` from StackPort's environment;
11
+ 3. ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` from StackPort's environment,
12
+ with ``AWS_SESSION_TOKEN`` when it is set (temporary credentials). The token
13
+ belongs to that pair only: no other source below or above ever gets it;
12
14
  4. placeholder keys when AWS Emulator's folder exists without keys (it runs
13
15
  with ``--auth off``, or it is stopped), so the default chain never sends the
14
16
  access key ID of a real account to it, and a stopped emulator shows up as
@@ -32,6 +34,7 @@ A plan is hashable and carries the emulator files' stat stamp, so
32
34
 
33
35
  from __future__ import annotations
34
36
 
37
+ import hashlib
35
38
  import os
36
39
  import ssl
37
40
  from dataclasses import dataclass, field
@@ -43,7 +46,7 @@ from urllib3.exceptions import LocationParseError
43
46
  from urllib3.util import parse_url
44
47
 
45
48
  from backend import aws_emulator
46
- from backend.config import AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, is_local_endpoint
49
+ from backend.config import AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, is_local_endpoint
47
50
 
48
51
  CredentialSource = Literal[
49
52
  "endpoint-profile",
@@ -95,14 +98,21 @@ class EndpointInfo:
95
98
  """Prefix for cache keys holding this endpoint's results.
96
99
 
97
100
  Two saved endpoints can share a URL with different auth or TLS settings,
98
- so all of them are in it (never the secret key). It starts with the URL,
99
- so editing an endpoint, which drops ``"<url>:"`` keys, drops these too.
101
+ so all of them are in it. The secret key is there only as a short
102
+ digest, enough to tell two keys apart and useless for recovering one.
103
+ It starts with the URL, so editing an endpoint, which drops
104
+ ``"<url>:"`` keys, drops these too.
100
105
  """
106
+ secret_digest = (
107
+ hashlib.sha256(self.auth_secret_access_key.encode()).hexdigest()[:12]
108
+ if self.auth_secret_access_key
109
+ else None
110
+ )
101
111
  return ":".join(
102
112
  str(part)
103
113
  for part in (
104
114
  self.url, self.region, self.auth_type, self.auth_profile,
105
- self.auth_access_key_id, self.tls_verify, self.ca_bundle,
115
+ self.auth_access_key_id, secret_digest, self.tls_verify, self.ca_bundle,
106
116
  )
107
117
  )
108
118
 
@@ -131,6 +141,7 @@ class ConnectionPlan:
131
141
  profile: str | None = None
132
142
  access_key_id: str | None = field(default=None, repr=False)
133
143
  secret_access_key: str | None = field(default=None, repr=False)
144
+ session_token: str | None = field(default=None, repr=False)
134
145
  # None leaves verification to botocore (system trust, or AWS_CA_BUNDLE).
135
146
  verify: bool | str | None = None
136
147
  # A local emulator rather than AWS: see aws_client._build_client for what changes.
@@ -142,6 +153,15 @@ class InvalidEndpointUrl(ValueError):
142
153
  """An endpoint URL StackPort refuses to use."""
143
154
 
144
155
 
156
+ def is_direct_endpoint_url(value: str | None) -> bool:
157
+ """True when an endpoint reference is a URL rather than a saved endpoint's name.
158
+
159
+ The scheme is case-insensitive (``HTTP://host`` is a URL); whether the URL
160
+ is acceptable is ``check_endpoint_url``'s call.
161
+ """
162
+ return value is not None and value.lower().startswith(("http://", "https://"))
163
+
164
+
145
165
  def check_endpoint_url(url: str) -> str:
146
166
  """Return ``url`` if it is a plain http(s) URL every parser reads the same way.
147
167
 
@@ -202,7 +222,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
202
222
  files = aws_emulator.discover(ep.url) if ep.url and not (has_own_credentials and has_own_trust) else None
203
223
 
204
224
  # Credentials
205
- profile = key = secret = None
225
+ profile = key = secret = token = None
206
226
  source: CredentialSource
207
227
  if ep.auth_type == "profile" and ep.auth_profile:
208
228
  source, profile = "endpoint-profile", ep.auth_profile
@@ -212,7 +232,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
212
232
  key, secret = aws_emulator.read_credentials(files.credentials, files.stamp[1])
213
233
  source = "aws-emulator"
214
234
  elif AWS_ACCESS_KEY_ID is not None or AWS_SECRET_ACCESS_KEY is not None:
215
- source, key, secret = "environment", AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
235
+ source, key, secret, token = "environment", AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
216
236
  elif files is not None:
217
237
  source, key, secret = "placeholder", PLACEHOLDER_ACCESS_KEY_ID, PLACEHOLDER_SECRET_ACCESS_KEY
218
238
  else:
@@ -240,6 +260,7 @@ def plan_connection(ep: EndpointInfo) -> ConnectionPlan:
240
260
  profile=profile,
241
261
  access_key_id=key,
242
262
  secret_access_key=secret,
263
+ session_token=token,
243
264
  verify=verify,
244
265
  local_endpoint=is_local_endpoint(ep.url),
245
266
  emulator_files=files,
@@ -0,0 +1,64 @@
1
+ """Passing a download from an endpoint on to the browser: S3 objects, Lambda code packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import Protocol
8
+ from urllib.parse import quote
9
+
10
+ from fastapi.responses import StreamingResponse
11
+ from starlette.types import Receive, Scope, Send
12
+
13
+ _PLAIN_FILENAME = re.compile(r"[A-Za-z0-9._-]+")
14
+
15
+ # How much of the upstream body to read for each chunk sent on. Reading a sync
16
+ # body costs a hop through the thread pool per chunk, so tiny chunks make a
17
+ # download crawl; at this size, relaying keeps pace with reading directly.
18
+ RELAY_CHUNK_BYTES = 64 * 1024
19
+
20
+
21
+ class Closeable(Protocol):
22
+ def close(self) -> None: ...
23
+
24
+
25
+ def attachment(filename: str) -> str:
26
+ """A Content-Disposition value that saves the download as ``filename`` (RFC 6266).
27
+
28
+ A name outside a conservative ASCII set gets an underscored fallback plus the
29
+ exact name, percent-encoded as UTF-8, in ``filename*`` (RFC 8187), so quotes,
30
+ backslashes, semicolons, line breaks and characters outside Latin-1 can't
31
+ break the header. The fallback replaces quotes and backslashes rather than
32
+ escaping them, since some browsers don't unescape them (RFC 6266, appendix D).
33
+ """
34
+ if _PLAIN_FILENAME.fullmatch(filename):
35
+ return f'attachment; filename="{filename}"'
36
+ fallback = re.sub(r"[^A-Za-z0-9._-]", "_", filename)
37
+ return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
38
+
39
+
40
+ class RelayResponse(StreamingResponse):
41
+ """Streams a body read from an open upstream response, and closes the upstream however the response ends.
42
+
43
+ When the client goes away, Starlette stops iterating the body without
44
+ closing it, so the body iterator can't be the one that closes the upstream.
45
+ An error while reading the upstream propagates, so the server drops the
46
+ connection instead of finishing a cut-off download that would look complete.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ content: Iterable[bytes],
52
+ *,
53
+ upstream: Closeable,
54
+ media_type: str,
55
+ headers: Mapping[str, str],
56
+ ):
57
+ super().__init__(content, media_type=media_type, headers=headers)
58
+ self._upstream = upstream
59
+
60
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
61
+ try:
62
+ await super().__call__(scope, receive, send)
63
+ finally:
64
+ self._upstream.close()
@@ -0,0 +1,114 @@
1
+ """Extra shell setup the AWS CLI needs for the endpoint a learner is using.
2
+
3
+ Lessons show commands like ``aws --endpoint-url={{endpointUrl}} s3 ls``. That
4
+ is all MiniStack, Floci, LocalStack and Moto need. Some emulators also want
5
+ environment variables, for example AWS Emulator, which serves HTTPS with a
6
+ certificate and keys it creates at startup. The console steps work without
7
+ any of that because StackPort reads the same files itself, so only the CLI
8
+ path needs the learner to be told.
9
+
10
+ ``CLI_SETUP`` maps an emulator id (as reported by ``backend.emulators``) to a
11
+ function returning the setup for it. An emulator that needs nothing has no
12
+ entry, and the lesson shows nothing extra.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shlex
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from backend import aws_emulator
23
+ from backend.aws_emulator import AwsEmulatorFilesError
24
+ from backend.connection import ConnectionPlan, plan_connection
25
+ from backend.emulators import detect_emulator_quickly
26
+ from backend.routes.common import EndpointInfo
27
+
28
+ # Shell credentials outrank AWS_SHARED_CREDENTIALS_FILE, so leftovers would
29
+ # sign requests with keys the emulator does not know.
30
+ _CLEAR_CREDENTIALS = "unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CliSetup:
35
+ """Shell lines to run once, and an optional sentence about where they come from."""
36
+
37
+ lines: list[str]
38
+ note: str | None = None
39
+
40
+
41
+ def running_in_container() -> bool:
42
+ """True inside Docker or Podman, where StackPort's filesystem is not the learner's."""
43
+ return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
44
+
45
+
46
+ def _shell_path(path: Path) -> str:
47
+ """Render a path for pasting into a shell, keeping ``~`` for the home folder."""
48
+ shown = aws_emulator.display_path(path)
49
+ if not shown.startswith("~/"):
50
+ return shlex.quote(shown)
51
+ rest = shown[2:]
52
+ quoted = shlex.quote(rest)
53
+ return f"~/{rest}" if quoted == rest else f'"$HOME"/{quoted}'
54
+
55
+
56
+ def _aws_emulator_setup(ep: EndpointInfo, plan: ConnectionPlan) -> CliSetup | None:
57
+ # Only when StackPort itself uses the emulator's files: an endpoint with its
58
+ # own keys or CA bundle is not one the CLI needs them for.
59
+ if "aws-emulator" not in (plan.credential_source, plan.trust_source):
60
+ return None
61
+ directory = aws_emulator.files_dir_for(ep.url)
62
+ if directory is None:
63
+ return None
64
+
65
+ note = None
66
+ if running_in_container():
67
+ # The folder StackPort sees is inside its container. AWS Emulator's own
68
+ # guide puts the files in the home folder of the machine running it.
69
+ port = directory.name
70
+ directory = Path.home() / ".aws" / "emulator" / port
71
+ note = (
72
+ "StackPort runs in a container, so these are the paths on your machine, where AWS Emulator "
73
+ "keeps its files when it runs there. With the Compose example the files live in the emulator's "
74
+ "volume: copy them out with `mkdir -p ~/.aws/emulator && docker compose -f "
75
+ f"docker-compose.aws-emulator.yml cp aws-emulator:/home/emulator/.aws/emulator/{port} "
76
+ "~/.aws/emulator/`, again after each restart."
77
+ )
78
+ return CliSetup(
79
+ lines=[
80
+ _CLEAR_CREDENTIALS,
81
+ f"export AWS_SHARED_CREDENTIALS_FILE={_shell_path(directory / aws_emulator.CREDENTIALS_FILE)}",
82
+ f"export AWS_CA_BUNDLE={_shell_path(directory / aws_emulator.CA_FILE)}",
83
+ # The emulator serves every service on one host, so the CLI must not
84
+ # prepend a service name to it (for example "s3-control.")
85
+ "export AWS_DISABLE_HOST_PREFIX_INJECTION=true",
86
+ ],
87
+ note=note,
88
+ )
89
+
90
+
91
+ CLI_SETUP: dict[str, Callable[[EndpointInfo, ConnectionPlan], CliSetup | None]] = {
92
+ "aws-emulator": _aws_emulator_setup,
93
+ }
94
+
95
+
96
+ def cli_setup_for(ep: EndpointInfo) -> dict | None:
97
+ """The setup block for this endpoint, or None when the plain command works.
98
+
99
+ ``{"emulator": <display name>, "script": <shell lines>, "note": <sentence or None>}``.
100
+ Never probes an endpoint that is not saved, and waits only briefly for an
101
+ emulator that has not been identified yet, so the UI asks again later.
102
+ """
103
+ emulator = detect_emulator_quickly(ep)
104
+ builder = CLI_SETUP.get(emulator["id"]) if emulator else None
105
+ if builder is None:
106
+ return None
107
+ try:
108
+ plan = plan_connection(ep)
109
+ except AwsEmulatorFilesError:
110
+ return None
111
+ setup = builder(ep, plan)
112
+ if setup is None:
113
+ return None
114
+ return {"emulator": emulator["name"], "script": "\n".join(setup.lines), "note": setup.note}
@@ -98,7 +98,7 @@ def _dynamodb_item_count(ep: EndpointInfo, params: dict) -> dict:
98
98
 
99
99
  def _resource_exists(ep: EndpointInfo, params: dict) -> dict:
100
100
  """Generic existence check driven by SERVICE_REGISTRY (no cache)."""
101
- from backend.routes.stats import _METHOD_KWARGS, SERVICE_REGISTRY
101
+ from backend.routes.stats import SERVICE_REGISTRY, list_items
102
102
 
103
103
  service = params["service"]
104
104
  resource_type = params["resource_type"]
@@ -108,12 +108,7 @@ def _resource_exists(ep: EndpointInfo, params: dict) -> dict:
108
108
  if rtype != resource_type:
109
109
  continue
110
110
  client = get_client(boto_service, **ep.client_kwargs())
111
- kwargs = _METHOD_KWARGS.get((boto_service, method), {})
112
- response = getattr(client, method)(**kwargs)
113
- items = response.get(response_key, [])
114
- if isinstance(items, dict) and "Items" in items:
115
- items = items["Items"]
116
- for item in items:
111
+ for item in list_items(client, boto_service, method, response_key):
117
112
  if isinstance(item, str) and target in item:
118
113
  return {"passed": True, "message": f"Found {resource_type[:-1] if resource_type.endswith('s') else resource_type} '{target}'."}
119
114
  if isinstance(item, dict) and any(target in str(v) for v in item.values() if isinstance(v, str)):