stackport 0.4.1__tar.gz → 0.4.2__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.
- stackport-0.4.2/MANIFEST.in +2 -0
- {stackport-0.4.1/stackport.egg-info → stackport-0.4.2}/PKG-INFO +20 -2
- {stackport-0.4.1 → stackport-0.4.2}/README.md +19 -1
- {stackport-0.4.1 → stackport-0.4.2}/backend/config.py +3 -0
- stackport-0.4.2/backend/learn/__init__.py +1 -0
- stackport-0.4.2/backend/learn/content.py +107 -0
- stackport-0.4.2/backend/learn/hotspots.py +27 -0
- stackport-0.4.2/backend/learn/trails/aws-first-steps.json +171 -0
- stackport-0.4.2/backend/learn/verify.py +172 -0
- stackport-0.4.2/backend/learn_store.py +130 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/main.py +10 -2
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/dynamodb.py +55 -1
- stackport-0.4.2/backend/routes/learn.py +233 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/s3.py +43 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/stats.py +2 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/dynamodb.py +22 -0
- stackport-0.4.2/backend/schemas/learn.py +44 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/s3.py +51 -1
- {stackport-0.4.1 → stackport-0.4.2}/pyproject.toml +2 -1
- {stackport-0.4.1 → stackport-0.4.2/stackport.egg-info}/PKG-INFO +20 -2
- {stackport-0.4.1 → stackport-0.4.2}/stackport.egg-info/SOURCES.txt +52 -36
- stackport-0.4.2/tests/test_create_resources.py +238 -0
- stackport-0.4.2/tests/test_learn_content.py +241 -0
- stackport-0.4.2/tests/test_learn_routes.py +284 -0
- stackport-0.4.2/tests/test_learn_store.py +52 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeAbout-DccVyQFu.js +1 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeDashboard-DtV4-eQq.js +1 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeLearn-DW6RZAj4.js +1 -0
- stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DNa6P8a7.css → stackport-0.4.2/ui/dist/assets/CloudscapeResourceBrowser-CzwePG-V.css +1 -1
- stackport-0.4.2/ui/dist/assets/CloudscapeResourceBrowser-FpsXtjf_.js +19 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeSettings-UdgiI6dm.js +1 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeShell-BXHVWjDE.css +1 -0
- stackport-0.4.2/ui/dist/assets/CloudscapeShell-Be6uFzsz.js +195 -0
- stackport-0.4.1/ui/dist/assets/StateMachineGraph-wwDpbmDc.js → stackport-0.4.2/ui/dist/assets/StateMachineGraph-G76o42iO.js +1 -1
- stackport-0.4.2/ui/dist/assets/index-B33aGwWE.js +1 -0
- stackport-0.4.2/ui/dist/assets/index-B3bka_BF.js +1 -0
- stackport-0.4.2/ui/dist/assets/index-B3zGVRXf.js +1 -0
- stackport-0.4.2/ui/dist/assets/index-CNWD72L_.js +1 -0
- stackport-0.4.2/ui/dist/assets/index-DKIdXFX0.css +1 -0
- stackport-0.4.1/ui/dist/assets/index-DaXIyw62.css → stackport-0.4.2/ui/dist/assets/index-DNBdeKId.css +1 -1
- stackport-0.4.2/ui/dist/assets/index-DhD1oh4N.js +1 -0
- stackport-0.4.2/ui/dist/assets/index-DoY0HscW.css +1 -0
- stackport-0.4.2/ui/dist/assets/index-DrkkXAoG.css +1 -0
- stackport-0.4.2/ui/dist/assets/index-Vf0UFiCT.css +1 -0
- stackport-0.4.2/ui/dist/assets/index-hhBdqMqs.js +67 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/index.html +2 -2
- stackport-0.4.1/MANIFEST.in +0 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeAbout-BFh7x4BC.js +0 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeDashboard-DzRbBwTo.js +0 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeResourceBrowser-DZE3EncJ.js +0 -19
- stackport-0.4.1/ui/dist/assets/CloudscapeSettings-BOzr9FgE.js +0 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeShell-C1R1SIYZ.css +0 -1
- stackport-0.4.1/ui/dist/assets/CloudscapeShell-KCrV2gOE.js +0 -201
- stackport-0.4.1/ui/dist/assets/index-BKtAPb3s.js +0 -1
- stackport-0.4.1/ui/dist/assets/index-BVHxAbst.js +0 -1
- stackport-0.4.1/ui/dist/assets/index-C_MN8WBt.js +0 -1
- stackport-0.4.1/ui/dist/assets/index-Cah5DqCf.css +0 -1
- stackport-0.4.1/ui/dist/assets/index-D7KAiUgR.js +0 -52
- stackport-0.4.1/ui/dist/assets/index-DRZKHoZl.css +0 -1
- stackport-0.4.1/ui/dist/assets/index-F7IxH4zH.js +0 -1
- stackport-0.4.1/ui/dist/assets/index-bpZOyX6N.js +0 -1
- stackport-0.4.1/ui/dist/assets/index-siFAyqi4.css +0 -1
- {stackport-0.4.1 → stackport-0.4.2}/LICENSE +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/__init__.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/aws_client.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/cache.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/cli.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/endpoint_store.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/__init__.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/common.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/ec2.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/endpoints.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/iam.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/logs.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/monitoring.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/rds.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/resources.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/sns.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/sqs.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/stepfunctions.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/routes/tags.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/__init__.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/endpoints.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/lambda_svc.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/logs.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/monitoring.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/secretsmanager.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/sns.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/sqs.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/stepfunctions.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/schemas/tags.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/backend/websocket.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/setup.cfg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_cache.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_cli.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_client.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_config.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_ec2_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_endpoint_store.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_endpoints.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_iam_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_lambda_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_logs_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_logs_tail.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_monitoring_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_rds_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_registries.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_s3_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_sns_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_sqs_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_tags_routes.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/tests/test_websocket.py +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/assets/index-DE--A2EE.css +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/assets/stepfunctions-graph-B6nvU4OB.js +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/assets/utils-Cfmwk2UT.js +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/favicon.png +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/ui/dist/favicon.svg +0 -0
- {stackport-0.4.1 → stackport-0.4.2}/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.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -70,6 +70,7 @@ 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
|
|
@@ -206,6 +207,21 @@ You can test connections before saving using the "Test Connection" button in the
|
|
|
206
207
|
|
|
207
208
|
All other services use a searchable resource table with JSON detail view, pagination, and export (JSON/CSV).
|
|
208
209
|
|
|
210
|
+
## Learn
|
|
211
|
+
|
|
212
|
+
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.
|
|
213
|
+
|
|
214
|
+
Open **Learn** in the sidebar (or press `g l`) and start a lesson. Every step gives you two ways through it:
|
|
215
|
+
|
|
216
|
+
- **Do it here** — the popover points at the control to use, with the exact values to type, copyable.
|
|
217
|
+
- **With the CLI** — the command, already filled in with your endpoint and your resource names, plus an explanation of what each flag does.
|
|
218
|
+
|
|
219
|
+
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.
|
|
220
|
+
|
|
221
|
+
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.
|
|
222
|
+
|
|
223
|
+
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.
|
|
224
|
+
|
|
209
225
|
## Tag Management
|
|
210
226
|
|
|
211
227
|
Unified tag read/write across 21 resource types:
|
|
@@ -244,6 +260,7 @@ Press `?` anywhere to see all shortcuts.
|
|
|
244
260
|
| `b` | Toggle sidebar |
|
|
245
261
|
| `g d` | Go to Dashboard |
|
|
246
262
|
| `g r` | Go to Resources |
|
|
263
|
+
| `g l` | Go to Learn |
|
|
247
264
|
| `/` | Focus search |
|
|
248
265
|
| `j` / `k` | Navigate up/down in resource list |
|
|
249
266
|
| `[` / `]` | Previous/next service |
|
|
@@ -263,12 +280,13 @@ Press `?` anywhere to see all shortcuts.
|
|
|
263
280
|
| `STACKPORT_PORT` | `8080` | StackPort server port |
|
|
264
281
|
| `STACKPORT_ALLOW_WRITES` | `true` | Enable write operations (POST/PUT/DELETE) |
|
|
265
282
|
| `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max S3 upload size per object (MiB) |
|
|
283
|
+
| `STACKPORT_LEARN` | `true` | Guided tutorials. `false` removes the Learn routes and UI |
|
|
266
284
|
| `STACKPORT_SERVICES` | *(35 services)* | Comma-separated list of services to probe |
|
|
267
285
|
| `STACKPORT_PROBE_TIMEOUT` | `5` | Seconds before a service probe times out |
|
|
268
286
|
| `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
|
|
269
287
|
| `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
|
|
270
288
|
| `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
|
|
271
|
-
| `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
|
|
289
|
+
| `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json, learn_progress.json) |
|
|
272
290
|
| `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
|
|
273
291
|
|
|
274
292
|
## Supported Services (35)
|
|
@@ -34,6 +34,7 @@
|
|
|
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
|
|
@@ -170,6 +171,21 @@ You can test connections before saving using the "Test Connection" button in the
|
|
|
170
171
|
|
|
171
172
|
All other services use a searchable resource table with JSON detail view, pagination, and export (JSON/CSV).
|
|
172
173
|
|
|
174
|
+
## Learn
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
Open **Learn** in the sidebar (or press `g l`) and start a lesson. Every step gives you two ways through it:
|
|
179
|
+
|
|
180
|
+
- **Do it here** — the popover points at the control to use, with the exact values to type, copyable.
|
|
181
|
+
- **With the CLI** — the command, already filled in with your endpoint and your resource names, plus an explanation of what each flag does.
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
173
189
|
## Tag Management
|
|
174
190
|
|
|
175
191
|
Unified tag read/write across 21 resource types:
|
|
@@ -208,6 +224,7 @@ Press `?` anywhere to see all shortcuts.
|
|
|
208
224
|
| `b` | Toggle sidebar |
|
|
209
225
|
| `g d` | Go to Dashboard |
|
|
210
226
|
| `g r` | Go to Resources |
|
|
227
|
+
| `g l` | Go to Learn |
|
|
211
228
|
| `/` | Focus search |
|
|
212
229
|
| `j` / `k` | Navigate up/down in resource list |
|
|
213
230
|
| `[` / `]` | Previous/next service |
|
|
@@ -227,12 +244,13 @@ Press `?` anywhere to see all shortcuts.
|
|
|
227
244
|
| `STACKPORT_PORT` | `8080` | StackPort server port |
|
|
228
245
|
| `STACKPORT_ALLOW_WRITES` | `true` | Enable write operations (POST/PUT/DELETE) |
|
|
229
246
|
| `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max S3 upload size per object (MiB) |
|
|
247
|
+
| `STACKPORT_LEARN` | `true` | Guided tutorials. `false` removes the Learn routes and UI |
|
|
230
248
|
| `STACKPORT_SERVICES` | *(35 services)* | Comma-separated list of services to probe |
|
|
231
249
|
| `STACKPORT_PROBE_TIMEOUT` | `5` | Seconds before a service probe times out |
|
|
232
250
|
| `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
|
|
233
251
|
| `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
|
|
234
252
|
| `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
|
|
235
|
-
| `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
|
|
253
|
+
| `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json, learn_progress.json) |
|
|
236
254
|
| `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
|
|
237
255
|
|
|
238
256
|
## Supported Services (35)
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "aws-first-steps",
|
|
3
|
+
"title": "AWS first steps",
|
|
4
|
+
"description": "Learn core AWS services by building against your local emulator. You do the work in the console or the CLI, and StackPort checks the real resource state, so a step only turns green when the thing genuinely exists. Nothing here costs money or touches a real account.",
|
|
5
|
+
"lessons": [
|
|
6
|
+
{
|
|
7
|
+
"id": "s3-store-a-file",
|
|
8
|
+
"title": "Store a file",
|
|
9
|
+
"summary": "Create a bucket, put a file in it, and read back what S3 recorded about it.",
|
|
10
|
+
"service": "s3",
|
|
11
|
+
"level": "Beginner",
|
|
12
|
+
"durationMinutes": 6,
|
|
13
|
+
"docsUrl": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html",
|
|
14
|
+
"variables": [
|
|
15
|
+
{
|
|
16
|
+
"name": "bucket",
|
|
17
|
+
"label": "Your bucket name",
|
|
18
|
+
"default": "stackport-learn-{{suffix}}",
|
|
19
|
+
"description": "You will create this bucket, and every command in the lesson will use the name you pick.",
|
|
20
|
+
"pattern": "^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$",
|
|
21
|
+
"patternMessage": "Bucket names use lowercase letters, numbers and hyphens, and must start and end with a letter or number."
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"steps": [
|
|
25
|
+
{
|
|
26
|
+
"id": "find-s3",
|
|
27
|
+
"title": "Find S3",
|
|
28
|
+
"instruction": "S3 is where AWS keeps files. Not a filesystem and not a disk you attach to a server: a service you send files to over HTTP, one file at a time, and it hands them back on request.\n\nOpen the S3 view. This is the same navigation you would use in the AWS console, and the list you are about to see is read live from your emulator.",
|
|
29
|
+
"console": {
|
|
30
|
+
"hotspotId": "nav-resources",
|
|
31
|
+
"route": "/resources/s3",
|
|
32
|
+
"label": "Resources, then S3 in the service list"
|
|
33
|
+
},
|
|
34
|
+
"commands": {
|
|
35
|
+
"cli": "aws --endpoint-url={{endpointUrl}} s3 ls"
|
|
36
|
+
},
|
|
37
|
+
"cliNotes": [
|
|
38
|
+
{
|
|
39
|
+
"flag": "--endpoint-url",
|
|
40
|
+
"note": "Points the CLI at your emulator instead of real AWS. Drop this one flag and the exact same command talks to a real account. It is worth staring at for a second, because it is the only difference."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"flag": "s3 ls",
|
|
44
|
+
"note": "With nothing after it, lists your buckets. With a bucket name after it, lists what is inside."
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"whyItMatters": "Every AWS API call goes to a URL. The CLI normally computes that URL from the service and the region, which is why you never see it. Overriding it is what makes local emulators possible, and also what makes a mistyped endpoint fail in confusing ways.",
|
|
48
|
+
"verify": null,
|
|
49
|
+
"hint": "Empty output is the correct answer if you have no buckets yet."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "create-bucket",
|
|
53
|
+
"title": "Create your bucket",
|
|
54
|
+
"instruction": "A bucket is a named container, and the name is the part that matters. Bucket names live in one namespace shared by everyone using that AWS partition, which is why they are lowercase, DNS-shaped, and frequently already taken.\n\nCreate a bucket called {{bucket}}. Your emulator has no global namespace to collide with, but the naming rules are enforced anyway, because a name that works locally and is rejected in production is a bad lesson to learn twice.",
|
|
55
|
+
"console": {
|
|
56
|
+
"hotspotId": "s3-buckets-create",
|
|
57
|
+
"route": "/resources/s3",
|
|
58
|
+
"label": "Create bucket",
|
|
59
|
+
"fields": [
|
|
60
|
+
{ "label": "Bucket name", "value": "{{bucket}}" }
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
"commands": {
|
|
64
|
+
"cli": "aws --endpoint-url={{endpointUrl}} s3api create-bucket --bucket {{bucket}}"
|
|
65
|
+
},
|
|
66
|
+
"cliNotes": [
|
|
67
|
+
{
|
|
68
|
+
"flag": "s3api",
|
|
69
|
+
"note": "The one-to-one mapping onto the S3 API. `aws s3` is a friendlier layer on top with commands like cp and sync; `aws s3api` is what the API actually offers, which is what you want when you are learning what a service does."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"flag": "--bucket",
|
|
73
|
+
"note": "The name. In any region other than us-east-1 you would also need --create-bucket-configuration LocationConstraint=<region>, an old quirk of the API rather than a rule you can reason out."
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"whyItMatters": "Real S3 applies two defaults here that are worth knowing: server-side encryption with S3-managed keys is on and cannot be turned off, and Block Public Access is on for all four settings. If you ever find yourself switching that second one off, be certain you meant to.",
|
|
77
|
+
"verify": {
|
|
78
|
+
"type": "s3_bucket_exists",
|
|
79
|
+
"params": { "bucket": "{{bucket}}" }
|
|
80
|
+
},
|
|
81
|
+
"autoVerify": true,
|
|
82
|
+
"hint": "The name has to match {{bucket}} exactly, since that is what gets checked. If the command hangs instead of failing, your emulator is probably not running.",
|
|
83
|
+
"docsUrl": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "open-bucket",
|
|
87
|
+
"title": "Look inside it",
|
|
88
|
+
"instruction": "Open {{bucket}}. It is empty, which is the point worth pausing on: you created a container and paid for nothing, because S3 bills for the bytes you store and the requests you make, not for the bucket.\n\nA bucket is also flat. There are no directories inside it, only objects whose names happen to contain slashes.",
|
|
89
|
+
"console": {
|
|
90
|
+
"hotspotId": "s3-buckets-table",
|
|
91
|
+
"route": "/resources/s3",
|
|
92
|
+
"label": "Click {{bucket}} in the bucket list"
|
|
93
|
+
},
|
|
94
|
+
"commands": {
|
|
95
|
+
"cli": "aws --endpoint-url={{endpointUrl}} s3 ls s3://{{bucket}}"
|
|
96
|
+
},
|
|
97
|
+
"whyItMatters": "The flatness is not a detail. When a console shows you a folder called photos/, it is showing you every key beginning with \"photos/\" grouped under a heading it invented for the occasion. Listing 10,000 objects in one prefix costs the same as listing them across ten, and renaming a \"folder\" means copying every object to a new key.",
|
|
98
|
+
"verify": null,
|
|
99
|
+
"hint": "Nothing to see yet is the expected result. Mark this done and go put something in it."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "upload-object",
|
|
103
|
+
"title": "Put a file in it",
|
|
104
|
+
"instruction": "Store a file called hello.txt in {{bucket}}. Its name inside the bucket is its key, and the key is the whole address: bucket plus key identifies exactly one object.\n\nIn the console, use the upload control. From the CLI, create a file first and copy it up.",
|
|
105
|
+
"console": {
|
|
106
|
+
"hotspotId": "s3-upload",
|
|
107
|
+
"route": "/resources/s3",
|
|
108
|
+
"label": "Upload, then pick any small text file and name it hello.txt",
|
|
109
|
+
"fields": [
|
|
110
|
+
{ "label": "Key", "value": "hello.txt" }
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"commands": {
|
|
114
|
+
"cli": "echo \"hello from stackport\" > hello.txt\naws --endpoint-url={{endpointUrl}} s3 cp hello.txt s3://{{bucket}}/hello.txt"
|
|
115
|
+
},
|
|
116
|
+
"cliNotes": [
|
|
117
|
+
{
|
|
118
|
+
"flag": "s3 cp",
|
|
119
|
+
"note": "Copies one thing. Its sibling `s3 sync` compares a whole directory against a prefix and transfers only what differs, which is the one you want for deployments."
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"flag": "s3://{{bucket}}/hello.txt",
|
|
123
|
+
"note": "Bucket and key. Change the last part and you have stored a different object: S3 has no rename, only copy and delete."
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"verify": {
|
|
127
|
+
"type": "s3_object_exists",
|
|
128
|
+
"params": { "bucket": "{{bucket}}", "key": "hello.txt" }
|
|
129
|
+
},
|
|
130
|
+
"autoVerify": true,
|
|
131
|
+
"hint": "The key must be exactly hello.txt, at the top of the bucket rather than inside a prefix.",
|
|
132
|
+
"docsUrl": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "inspect-object",
|
|
136
|
+
"title": "Read what S3 recorded",
|
|
137
|
+
"instruction": "Open hello.txt and look at its metadata. Three fields are worth your attention.\n\n**Size** is what you are billed for, to the byte. **Content type** is what S3 will claim the file is when someone downloads it, and it is a guess made at upload time. **ETag** is a fingerprint of the content, which is how you can tell two objects apart without downloading either.",
|
|
138
|
+
"console": {
|
|
139
|
+
"hotspotId": "s3-objects-table",
|
|
140
|
+
"route": "/resources/s3",
|
|
141
|
+
"label": "Click hello.txt to open its details"
|
|
142
|
+
},
|
|
143
|
+
"commands": {
|
|
144
|
+
"cli": "aws --endpoint-url={{endpointUrl}} s3api head-object --bucket {{bucket}} --key hello.txt"
|
|
145
|
+
},
|
|
146
|
+
"cliNotes": [
|
|
147
|
+
{
|
|
148
|
+
"flag": "head-object",
|
|
149
|
+
"note": "Returns the metadata without the body, the same way an HTTP HEAD request does. Cheap, and the fastest way to answer \"is this object there and has it changed?\"."
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"whyItMatters": "Content type is the field that will eventually bite you. Upload an image with the wrong one and browsers download it instead of showing it; upload HTML as text/plain and visitors read your markup. S3 infers it from the file extension at upload time and never revisits that guess, so fixing it later means copying the object over itself with the right value.",
|
|
153
|
+
"verify": null,
|
|
154
|
+
"hint": "You are looking for size in bytes, content type, and ETag. Mark this done once you have found all three.",
|
|
155
|
+
"docsUrl": "https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html"
|
|
156
|
+
}
|
|
157
|
+
],
|
|
158
|
+
"completion": {
|
|
159
|
+
"title": "You built something real",
|
|
160
|
+
"summary": "Your emulator now holds a bucket named {{bucket}} with hello.txt inside it. Not a simulation of one: the same API calls, the same naming rules, the same metadata you would get from real S3.",
|
|
161
|
+
"takeaways": [
|
|
162
|
+
"A bucket is a named container in a shared namespace, and the name is the hard part.",
|
|
163
|
+
"An object is addressed by bucket plus key, and there are no folders, only keys with slashes in them.",
|
|
164
|
+
"Metadata is data: size is your bill, content type is a guess, and ETag is a fingerprint.",
|
|
165
|
+
"One flag, --endpoint-url, is all that separates a local command from one that hits a real account."
|
|
166
|
+
],
|
|
167
|
+
"nextHint": "Everything you just created is still there. Delete it with `aws --endpoint-url={{endpointUrl}} s3 rb s3://{{bucket}} --force`, or leave it and keep poking at it."
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Step verification for the Learn module.
|
|
2
|
+
|
|
3
|
+
Checks run directly against the selected endpoint with fresh boto3 calls
|
|
4
|
+
(never through the TTL cache) so a step verifies immediately after the
|
|
5
|
+
learner acts. Connection failures surface as status "service_unreachable"
|
|
6
|
+
instead of a silent "not done".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from botocore.exceptions import ClientError, ConnectTimeoutError, EndpointConnectionError
|
|
13
|
+
|
|
14
|
+
from backend.aws_client import get_client
|
|
15
|
+
from backend.routes.common import EndpointInfo
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _s3_bucket_exists(ep: EndpointInfo, params: dict) -> dict:
|
|
21
|
+
bucket = params["bucket"]
|
|
22
|
+
client = get_client("s3", **ep.client_kwargs())
|
|
23
|
+
try:
|
|
24
|
+
client.head_bucket(Bucket=bucket)
|
|
25
|
+
return {"passed": True, "message": f"Bucket '{bucket}' found."}
|
|
26
|
+
except ClientError:
|
|
27
|
+
return {"passed": False, "message": f"Bucket '{bucket}' not found yet."}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _s3_object_exists(ep: EndpointInfo, params: dict) -> dict:
|
|
31
|
+
"""One named object. Better UX than a count: the message can say what is missing."""
|
|
32
|
+
bucket = params["bucket"]
|
|
33
|
+
key = params["key"]
|
|
34
|
+
client = get_client("s3", **ep.client_kwargs())
|
|
35
|
+
try:
|
|
36
|
+
response = client.head_object(Bucket=bucket, Key=key)
|
|
37
|
+
except ClientError:
|
|
38
|
+
return {"passed": False, "message": f"No object '{key}' in '{bucket}' yet."}
|
|
39
|
+
size = response.get("ContentLength", 0)
|
|
40
|
+
return {"passed": True, "message": f"Found '{key}' in '{bucket}' ({size} bytes)."}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _s3_prefix_exists(ep: EndpointInfo, params: dict) -> dict:
|
|
44
|
+
"""A prefix "exists" when at least one key starts with it (S3 has no real folders)."""
|
|
45
|
+
bucket = params["bucket"]
|
|
46
|
+
prefix = params["prefix"]
|
|
47
|
+
client = get_client("s3", **ep.client_kwargs())
|
|
48
|
+
try:
|
|
49
|
+
response = client.list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=1)
|
|
50
|
+
except ClientError:
|
|
51
|
+
return {"passed": False, "message": f"Bucket '{bucket}' not found yet."}
|
|
52
|
+
if response.get("KeyCount", 0) > 0:
|
|
53
|
+
return {"passed": True, "message": f"Found keys under '{prefix}' in '{bucket}'."}
|
|
54
|
+
return {"passed": False, "message": f"Nothing stored under '{prefix}' in '{bucket}' yet."}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _s3_object_count(ep: EndpointInfo, params: dict) -> dict:
|
|
58
|
+
bucket = params["bucket"]
|
|
59
|
+
minimum = int(params.get("min", 1))
|
|
60
|
+
client = get_client("s3", **ep.client_kwargs())
|
|
61
|
+
try:
|
|
62
|
+
response = client.list_objects_v2(Bucket=bucket)
|
|
63
|
+
except ClientError:
|
|
64
|
+
return {"passed": False, "message": f"Bucket '{bucket}' not found yet."}
|
|
65
|
+
count = response.get("KeyCount", 0)
|
|
66
|
+
if count >= minimum:
|
|
67
|
+
return {"passed": True, "message": f"Found {count} object(s) in '{bucket}'."}
|
|
68
|
+
return {"passed": False, "message": f"Bucket '{bucket}' has {count} object(s); expected at least {minimum}."}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dynamodb_table_exists(ep: EndpointInfo, params: dict) -> dict:
|
|
72
|
+
table = params["table"]
|
|
73
|
+
client = get_client("dynamodb", **ep.client_kwargs())
|
|
74
|
+
try:
|
|
75
|
+
response = client.describe_table(TableName=table)
|
|
76
|
+
except ClientError:
|
|
77
|
+
return {"passed": False, "message": f"Table '{table}' not found yet."}
|
|
78
|
+
status = response.get("Table", {}).get("TableStatus", "")
|
|
79
|
+
expected_status = params.get("status")
|
|
80
|
+
if expected_status and status != expected_status:
|
|
81
|
+
return {"passed": False, "message": f"Table '{table}' exists but is {status}, expected {expected_status}."}
|
|
82
|
+
return {"passed": True, "message": f"Table '{table}' found ({status})."}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dynamodb_item_count(ep: EndpointInfo, params: dict) -> dict:
|
|
86
|
+
table = params["table"]
|
|
87
|
+
minimum = int(params.get("min", 1))
|
|
88
|
+
client = get_client("dynamodb", **ep.client_kwargs())
|
|
89
|
+
try:
|
|
90
|
+
response = client.scan(TableName=table, Select="COUNT")
|
|
91
|
+
except ClientError:
|
|
92
|
+
return {"passed": False, "message": f"Table '{table}' not found yet."}
|
|
93
|
+
count = response.get("Count", 0)
|
|
94
|
+
if count >= minimum:
|
|
95
|
+
return {"passed": True, "message": f"Found {count} item(s) in '{table}'."}
|
|
96
|
+
return {"passed": False, "message": f"Table '{table}' has {count} item(s); expected at least {minimum}."}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resource_exists(ep: EndpointInfo, params: dict) -> dict:
|
|
100
|
+
"""Generic existence check driven by SERVICE_REGISTRY (no cache)."""
|
|
101
|
+
from backend.routes.stats import _METHOD_KWARGS, SERVICE_REGISTRY
|
|
102
|
+
|
|
103
|
+
service = params["service"]
|
|
104
|
+
resource_type = params["resource_type"]
|
|
105
|
+
target = params["id"]
|
|
106
|
+
|
|
107
|
+
for rtype, boto_service, method, response_key in SERVICE_REGISTRY.get(service, []):
|
|
108
|
+
if rtype != resource_type:
|
|
109
|
+
continue
|
|
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:
|
|
117
|
+
if isinstance(item, str) and target in item:
|
|
118
|
+
return {"passed": True, "message": f"Found {resource_type[:-1] if resource_type.endswith('s') else resource_type} '{target}'."}
|
|
119
|
+
if isinstance(item, dict) and any(target in str(v) for v in item.values() if isinstance(v, str)):
|
|
120
|
+
return {"passed": True, "message": f"Found '{target}' in {service} {resource_type}."}
|
|
121
|
+
return {"passed": False, "message": f"'{target}' not found in {service} {resource_type} yet."}
|
|
122
|
+
return {"passed": False, "message": f"Unknown resource type {service}/{resource_type}."}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _all_of(ep: EndpointInfo, params: dict) -> dict:
|
|
126
|
+
"""Composite step: every nested check must pass. Reports the first one that doesn't."""
|
|
127
|
+
specs = params.get("checks", [])
|
|
128
|
+
if not specs:
|
|
129
|
+
return {"passed": False, "message": "This composite check has no sub-checks."}
|
|
130
|
+
for spec in specs:
|
|
131
|
+
check = CHECKS.get(spec.get("type", ""))
|
|
132
|
+
if check is None:
|
|
133
|
+
return {"passed": False, "message": f"Unknown check type '{spec.get('type')}'."}
|
|
134
|
+
result = check(ep, spec.get("params", {}))
|
|
135
|
+
if not result["passed"]:
|
|
136
|
+
return result
|
|
137
|
+
return {"passed": True, "message": f"All {len(specs)} conditions met."}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
CHECKS: dict[str, Callable[[EndpointInfo, dict], dict]] = {
|
|
141
|
+
"s3_bucket_exists": _s3_bucket_exists,
|
|
142
|
+
"s3_object_exists": _s3_object_exists,
|
|
143
|
+
"s3_prefix_exists": _s3_prefix_exists,
|
|
144
|
+
"s3_object_count": _s3_object_count,
|
|
145
|
+
"all_of": _all_of,
|
|
146
|
+
"dynamodb_table_exists": _dynamodb_table_exists,
|
|
147
|
+
"dynamodb_item_count": _dynamodb_item_count,
|
|
148
|
+
"resource_exists": _resource_exists,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_check(ep: EndpointInfo, spec: dict) -> dict:
|
|
153
|
+
"""Run a verification spec: {"type": <check name>, "params": {...}}.
|
|
154
|
+
|
|
155
|
+
Returns {"status": "ok"|"service_unreachable"|"error", "passed": bool, "message": str}.
|
|
156
|
+
"""
|
|
157
|
+
check = CHECKS.get(spec.get("type", ""))
|
|
158
|
+
if check is None:
|
|
159
|
+
return {"status": "error", "passed": False, "message": f"Unknown check type '{spec.get('type')}'."}
|
|
160
|
+
try:
|
|
161
|
+
result = check(ep, spec.get("params", {}))
|
|
162
|
+
result["status"] = "ok"
|
|
163
|
+
return result
|
|
164
|
+
except (EndpointConnectionError, ConnectTimeoutError):
|
|
165
|
+
return {
|
|
166
|
+
"status": "service_unreachable",
|
|
167
|
+
"passed": False,
|
|
168
|
+
"message": "Could not reach the endpoint. Is your emulator running?",
|
|
169
|
+
}
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.debug("Learn check failed unexpectedly", exc_info=True)
|
|
172
|
+
return {"status": "error", "passed": False, "message": str(e)}
|