stackport 0.1.7__tar.gz → 0.1.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 (93) hide show
  1. {stackport-0.1.7/stackport.egg-info → stackport-0.1.9}/PKG-INFO +25 -5
  2. {stackport-0.1.7 → stackport-0.1.9}/README.md +21 -4
  3. stackport-0.1.9/backend/aws_client.py +31 -0
  4. {stackport-0.1.7 → stackport-0.1.9}/backend/cache.py +4 -0
  5. stackport-0.1.9/backend/cli.py +259 -0
  6. stackport-0.1.9/backend/config.py +79 -0
  7. {stackport-0.1.7 → stackport-0.1.9}/backend/main.py +28 -4
  8. stackport-0.1.9/backend/routes/common.py +25 -0
  9. stackport-0.1.9/backend/routes/endpoints.py +33 -0
  10. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/resources.py +9 -8
  11. stackport-0.1.9/backend/routes/s3.py +356 -0
  12. stackport-0.1.9/backend/routes/secretsmanager.py +112 -0
  13. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/stats.py +9 -7
  14. stackport-0.1.9/backend/websocket.py +110 -0
  15. {stackport-0.1.7 → stackport-0.1.9}/pyproject.toml +9 -2
  16. {stackport-0.1.7 → stackport-0.1.9/stackport.egg-info}/PKG-INFO +25 -5
  17. {stackport-0.1.7 → stackport-0.1.9}/stackport.egg-info/SOURCES.txt +15 -4
  18. {stackport-0.1.7 → stackport-0.1.9}/stackport.egg-info/requires.txt +3 -0
  19. {stackport-0.1.7 → stackport-0.1.9}/tests/test_cache.py +11 -0
  20. stackport-0.1.9/tests/test_cli.py +122 -0
  21. stackport-0.1.9/tests/test_endpoints.py +98 -0
  22. stackport-0.1.9/tests/test_s3_routes.py +353 -0
  23. stackport-0.1.9/tests/test_s3_upload_limit_env.py +38 -0
  24. stackport-0.1.9/tests/test_secretsmanager_routes.py +281 -0
  25. stackport-0.1.9/tests/test_websocket.py +58 -0
  26. stackport-0.1.9/ui/dist/assets/index-CWKwl2Ak.css +1 -0
  27. stackport-0.1.9/ui/dist/assets/index-Det-eQBZ.js +552 -0
  28. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/index.html +2 -2
  29. stackport-0.1.7/backend/aws_client.py +0 -22
  30. stackport-0.1.7/backend/config.py +0 -15
  31. stackport-0.1.7/backend/routes/s3.py +0 -169
  32. stackport-0.1.7/ui/dist/assets/index-DI-V3ZCb.js +0 -527
  33. stackport-0.1.7/ui/dist/assets/index-DM3oKaVN.css +0 -1
  34. {stackport-0.1.7 → stackport-0.1.9}/LICENSE +0 -0
  35. {stackport-0.1.7 → stackport-0.1.9}/MANIFEST.in +0 -0
  36. {stackport-0.1.7 → stackport-0.1.9}/backend/__init__.py +0 -0
  37. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/__init__.py +0 -0
  38. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/dynamodb.py +0 -0
  39. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/ec2.py +0 -0
  40. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/iam.py +0 -0
  41. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/lambda_svc.py +0 -0
  42. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/logs.py +0 -0
  43. {stackport-0.1.7 → stackport-0.1.9}/backend/routes/sqs.py +0 -0
  44. {stackport-0.1.7 → stackport-0.1.9}/setup.cfg +0 -0
  45. {stackport-0.1.7 → stackport-0.1.9}/stackport.egg-info/dependency_links.txt +0 -0
  46. {stackport-0.1.7 → stackport-0.1.9}/stackport.egg-info/entry_points.txt +0 -0
  47. {stackport-0.1.7 → stackport-0.1.9}/stackport.egg-info/top_level.txt +0 -0
  48. {stackport-0.1.7 → stackport-0.1.9}/tests/test_client.py +0 -0
  49. {stackport-0.1.7 → stackport-0.1.9}/tests/test_config.py +0 -0
  50. {stackport-0.1.7 → stackport-0.1.9}/tests/test_dynamodb_routes.py +0 -0
  51. {stackport-0.1.7 → stackport-0.1.9}/tests/test_ec2_routes.py +0 -0
  52. {stackport-0.1.7 → stackport-0.1.9}/tests/test_iam_routes.py +0 -0
  53. {stackport-0.1.7 → stackport-0.1.9}/tests/test_lambda_routes.py +0 -0
  54. {stackport-0.1.7 → stackport-0.1.9}/tests/test_logs_routes.py +0 -0
  55. {stackport-0.1.7 → stackport-0.1.9}/tests/test_registries.py +0 -0
  56. {stackport-0.1.7 → stackport-0.1.9}/tests/test_routes.py +0 -0
  57. {stackport-0.1.7 → stackport-0.1.9}/tests/test_sqs_routes.py +0 -0
  58. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/acm.svg +0 -0
  59. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/apigateway.svg +0 -0
  60. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/appsync.svg +0 -0
  61. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/athena.svg +0 -0
  62. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/cloudformation.svg +0 -0
  63. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/cloudfront.svg +0 -0
  64. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  65. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/dynamodb.svg +0 -0
  66. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/ec2.svg +0 -0
  67. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/ecr.svg +0 -0
  68. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/ecs.svg +0 -0
  69. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/elasticache.svg +0 -0
  70. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  71. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  72. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  73. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/events.svg +0 -0
  74. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/firehose.svg +0 -0
  75. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/glue.svg +0 -0
  76. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/iam.svg +0 -0
  77. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/kinesis.svg +0 -0
  78. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/kms.svg +0 -0
  79. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/lambda.svg +0 -0
  80. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/logs.svg +0 -0
  81. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/monitoring.svg +0 -0
  82. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/rds.svg +0 -0
  83. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/route53.svg +0 -0
  84. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/s3.svg +0 -0
  85. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  86. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/ses.svg +0 -0
  87. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/sns.svg +0 -0
  88. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/sqs.svg +0 -0
  89. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/ssm.svg +0 -0
  90. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  91. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/aws-icons/wafv2.svg +0 -0
  92. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/favicon.png +0 -0
  93. {stackport-0.1.7 → stackport-0.1.9}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -24,6 +24,9 @@ License-File: LICENSE
24
24
  Requires-Dist: fastapi>=0.115.0
25
25
  Requires-Dist: uvicorn>=0.30.0
26
26
  Requires-Dist: boto3>=1.35.0
27
+ Requires-Dist: click>=8.0
28
+ Requires-Dist: websockets>=12.0
29
+ Requires-Dist: python-multipart>=0.0.26
27
30
  Provides-Extra: dev
28
31
  Requires-Dist: pytest>=8.0; extra == "dev"
29
32
  Requires-Dist: httpx>=0.27.0; extra == "dev"
@@ -31,7 +34,7 @@ Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
31
34
  Dynamic: license-file
32
35
 
33
36
  <p align="center">
34
- <img src="docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
37
+ <img src="https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
35
38
  </p>
36
39
 
37
40
  <h1 align="center">StackPort</h1>
@@ -50,13 +53,13 @@ Dynamic: license-file
50
53
  ## Screenshots
51
54
 
52
55
  **Dashboard** — Service overview with resource counts and health status
53
- ![StackPort Dashboard](docs/images/dashboard.jpeg)
56
+ ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg)
54
57
 
55
58
  **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
56
- ![DynamoDB Resources](docs/images/dynamo.jpeg)
59
+ ![DynamoDB Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dynamo.jpeg)
57
60
 
58
61
  **S3 Browser** — File browser with folder navigation and object preview
59
- ![S3 Browser](docs/images/s3.jpeg)
62
+ ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg)
60
63
 
61
64
  ## Features
62
65
 
@@ -120,6 +123,7 @@ AWS_ENDPOINT_URL=http://my-emulator:4566 stackport
120
123
  | `AWS_ACCESS_KEY_ID` | `test` | AWS access key |
121
124
  | `AWS_SECRET_ACCESS_KEY` | `test` | AWS secret key |
122
125
  | `STACKPORT_PORT` | `8080` | StackPort server port |
126
+ | `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max upload size for a single S3 object, in whole **mebibytes (MiB)** (× 1024²). Example: `1000` for about 1000 MiB. Omit for the default (100 MiB). |
123
127
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated services to probe |
124
128
  | `LOG_LEVEL` | `INFO` | Python log level (DEBUG shows healthcheck logs) |
125
129
 
@@ -140,6 +144,22 @@ cd ui && npm install && npm run dev
140
144
 
141
145
  See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
142
146
 
147
+ ## Star History
148
+
149
+ <a href="https://star-history.com/#DaviReisVieira/stackport&Date">
150
+ <picture>
151
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date&theme=dark" />
152
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" />
153
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" width="100%" />
154
+ </picture>
155
+ </a>
156
+
157
+ ## Contributors
158
+
159
+ <a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
160
+ <img src="https://contrib.rocks/image?repo=DaviReisVieira/stackport" alt="Contributors" />
161
+ </a>
162
+
143
163
  ## License
144
164
 
145
165
  MIT
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
2
+ <img src="https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
3
3
  </p>
4
4
 
5
5
  <h1 align="center">StackPort</h1>
@@ -18,13 +18,13 @@
18
18
  ## Screenshots
19
19
 
20
20
  **Dashboard** — Service overview with resource counts and health status
21
- ![StackPort Dashboard](docs/images/dashboard.jpeg)
21
+ ![StackPort Dashboard](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dashboard.jpeg)
22
22
 
23
23
  **DynamoDB Browser** — Generic resource table with search, pagination, and detail view
24
- ![DynamoDB Resources](docs/images/dynamo.jpeg)
24
+ ![DynamoDB Resources](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/dynamo.jpeg)
25
25
 
26
26
  **S3 Browser** — File browser with folder navigation and object preview
27
- ![S3 Browser](docs/images/s3.jpeg)
27
+ ![S3 Browser](https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docs/images/s3.jpeg)
28
28
 
29
29
  ## Features
30
30
 
@@ -88,6 +88,7 @@ AWS_ENDPOINT_URL=http://my-emulator:4566 stackport
88
88
  | `AWS_ACCESS_KEY_ID` | `test` | AWS access key |
89
89
  | `AWS_SECRET_ACCESS_KEY` | `test` | AWS secret key |
90
90
  | `STACKPORT_PORT` | `8080` | StackPort server port |
91
+ | `STACKPORT_S3_MAX_UPLOAD_MB` | `100` | Max upload size for a single S3 object, in whole **mebibytes (MiB)** (× 1024²). Example: `1000` for about 1000 MiB. Omit for the default (100 MiB). |
91
92
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated services to probe |
92
93
  | `LOG_LEVEL` | `INFO` | Python log level (DEBUG shows healthcheck logs) |
93
94
 
@@ -108,6 +109,22 @@ cd ui && npm install && npm run dev
108
109
 
109
110
  See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
110
111
 
112
+ ## Star History
113
+
114
+ <a href="https://star-history.com/#DaviReisVieira/stackport&Date">
115
+ <picture>
116
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date&theme=dark" />
117
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" />
118
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=DaviReisVieira/stackport&type=Date" width="100%" />
119
+ </picture>
120
+ </a>
121
+
122
+ ## Contributors
123
+
124
+ <a href="https://github.com/DaviReisVieira/stackport/graphs/contributors">
125
+ <img src="https://contrib.rocks/image?repo=DaviReisVieira/stackport" alt="Contributors" />
126
+ </a>
127
+
111
128
  ## License
112
129
 
113
130
  MIT
@@ -0,0 +1,31 @@
1
+ import functools
2
+
3
+ import boto3
4
+
5
+ from backend.config import (
6
+ AWS_ACCESS_KEY_ID,
7
+ AWS_REGION,
8
+ AWS_SECRET_ACCESS_KEY,
9
+ DEFAULT_ENDPOINT,
10
+ )
11
+
12
+
13
+ @functools.lru_cache(maxsize=128)
14
+ def get_client(service_name: str, endpoint_url: str | None = None):
15
+ """Return a boto3 client for the given service and endpoint.
16
+
17
+ Args:
18
+ service_name: AWS service name (e.g., "s3", "dynamodb")
19
+ endpoint_url: Endpoint URL to use. If None, uses DEFAULT_ENDPOINT.
20
+
21
+ Returns:
22
+ Configured boto3 client
23
+ """
24
+ url = endpoint_url if endpoint_url is not None else DEFAULT_ENDPOINT
25
+ return boto3.client(
26
+ service_name,
27
+ endpoint_url=url,
28
+ region_name=AWS_REGION,
29
+ aws_access_key_id=AWS_ACCESS_KEY_ID,
30
+ aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
31
+ )
@@ -20,5 +20,9 @@ class TTLCache:
20
20
  with self._lock:
21
21
  self._store[key] = (value, time.time() + ttl)
22
22
 
23
+ def delete(self, key: str) -> None:
24
+ with self._lock:
25
+ self._store.pop(key, None)
26
+
23
27
 
24
28
  cache = TTLCache()
@@ -0,0 +1,259 @@
1
+ """CLI commands for StackPort."""
2
+
3
+ import csv
4
+ import json
5
+ import logging
6
+ import sys
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+
9
+ import click
10
+ import uvicorn
11
+
12
+ from backend.aws_client import get_client
13
+ from backend.config import AWS_ENDPOINT_URL, AWS_REGION, LOG_LEVEL, STACKPORT_PORT, STACKPORT_SERVICES
14
+ from backend.routes.resources import DESCRIBE_REGISTRY, _extract_id, _serialize
15
+ from backend.routes.stats import SERVICE_REGISTRY, _METHOD_KWARGS, _count_items, _probe_service
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @click.group(invoke_without_command=True)
21
+ @click.version_option(package_name="stackport")
22
+ @click.pass_context
23
+ def cli(ctx):
24
+ """StackPort - Universal AWS resource browser for local emulators."""
25
+ if ctx.invoked_subcommand is None:
26
+ ctx.invoke(serve)
27
+
28
+
29
+ @cli.command()
30
+ @click.option("--port", default=STACKPORT_PORT, help="HTTP port")
31
+ def serve(port):
32
+ """Start the StackPort web server (default command)."""
33
+ uvicorn.run("backend.main:app", host="0.0.0.0", port=port, log_level=LOG_LEVEL.lower(), reload=False)
34
+
35
+
36
+ @cli.command()
37
+ @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
38
+ @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
39
+ @click.option("--output", type=click.Choice(["json", "table"]), default="table", help="Output format")
40
+ def status(endpoint, region, output):
41
+ """Show all services with availability and resource counts."""
42
+ # Override endpoint/region if provided
43
+ if endpoint != AWS_ENDPOINT_URL:
44
+ import os
45
+
46
+ os.environ["AWS_ENDPOINT_URL"] = endpoint
47
+ if region != AWS_REGION:
48
+ import os
49
+
50
+ os.environ["AWS_REGION"] = region
51
+
52
+ enabled_services = [s.strip() for s in STACKPORT_SERVICES.split(",") if s.strip()]
53
+ services = {}
54
+
55
+ try:
56
+ with ThreadPoolExecutor(max_workers=min(len(enabled_services), 10)) as executor:
57
+ futures = {executor.submit(_probe_service, svc): svc for svc in enabled_services}
58
+ for future in as_completed(futures):
59
+ svc_name, result = future.result()
60
+ services[svc_name] = result
61
+ except Exception as exc:
62
+ click.echo(f"Error probing services: {exc}", err=True)
63
+ sys.exit(2)
64
+
65
+ sorted_services = dict(sorted(services.items()))
66
+
67
+ if output == "json":
68
+ click.echo(json.dumps(sorted_services, indent=2))
69
+ else:
70
+ # Table format
71
+ click.echo(f"{'SERVICE':<25} {'STATUS':<12} {'RESOURCES'}")
72
+ click.echo("-" * 60)
73
+ for svc_name, result in sorted_services.items():
74
+ status_str = result["status"]
75
+ resources = result.get("resources", {})
76
+ total = sum(resources.values())
77
+ resource_summary = ", ".join(f"{rt}={c}" for rt, c in resources.items() if c > 0) if resources else "-"
78
+ if len(resource_summary) > 30:
79
+ resource_summary = f"{total} total"
80
+ click.echo(f"{svc_name:<25} {status_str:<12} {resource_summary}")
81
+
82
+ sys.exit(0)
83
+
84
+
85
+ @cli.command()
86
+ @click.argument("service")
87
+ @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
88
+ @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
89
+ @click.option("--output", type=click.Choice(["json", "table", "csv"]), default="table", help="Output format")
90
+ def list(service, endpoint, region, output):
91
+ """List resources for a service."""
92
+ # Override endpoint/region if provided
93
+ if endpoint != AWS_ENDPOINT_URL:
94
+ import os
95
+
96
+ os.environ["AWS_ENDPOINT_URL"] = endpoint
97
+ if region != AWS_REGION:
98
+ import os
99
+
100
+ os.environ["AWS_REGION"] = region
101
+
102
+ registry_entries = SERVICE_REGISTRY.get(service)
103
+ if not registry_entries:
104
+ valid_services = ", ".join(sorted(SERVICE_REGISTRY.keys()))
105
+ click.echo(f"Error: Unknown service '{service}'.", err=True)
106
+ click.echo(f"Valid services: {valid_services}", err=True)
107
+ sys.exit(1)
108
+
109
+ resources = {}
110
+ for resource_type, boto3_service, method_name, response_key in registry_entries:
111
+ try:
112
+ client = get_client(boto3_service)
113
+ method = getattr(client, method_name)
114
+ kwargs = _METHOD_KWARGS.get((boto3_service, method_name), {})
115
+ resp = method(**kwargs)
116
+ items = resp.get(response_key, [])
117
+ # Handle nested structures (e.g., cloudfront DistributionList.Items)
118
+ if isinstance(items, dict) and "Items" in items:
119
+ items = items.get("Items", []) or []
120
+ resources[resource_type] = items
121
+ except Exception as exc:
122
+ logger.debug("Failed to list %s/%s: %s", service, resource_type, exc, exc_info=True)
123
+ resources[resource_type] = []
124
+
125
+ if output == "json":
126
+ serialized = {rt: [_serialize(item) if isinstance(item, dict) else item for item in items] for rt, items in resources.items()}
127
+ click.echo(json.dumps({"service": service, "resources": serialized}, indent=2))
128
+ elif output == "csv":
129
+ writer = csv.writer(sys.stdout)
130
+ writer.writerow(["service", "resource_type", "resource_id", "name"])
131
+ for resource_type, items in resources.items():
132
+ for item in items:
133
+ resource_id = _extract_id(item)
134
+ name = item.get("Name", item.get("FunctionName", item.get("TableName", resource_id))) if isinstance(item, dict) else resource_id
135
+ writer.writerow([service, resource_type, resource_id, name])
136
+ else:
137
+ # Table format
138
+ for resource_type, items in resources.items():
139
+ if items:
140
+ click.echo(f"\n{resource_type.upper()} ({len(items)}):")
141
+ click.echo("-" * 60)
142
+ for item in items[:20]: # Limit to first 20 for readability
143
+ resource_id = _extract_id(item)
144
+ if isinstance(item, dict):
145
+ name = item.get("Name", item.get("FunctionName", item.get("TableName", "")))
146
+ if name and name != resource_id:
147
+ click.echo(f" {resource_id} ({name})")
148
+ else:
149
+ click.echo(f" {resource_id}")
150
+ else:
151
+ click.echo(f" {item}")
152
+ if len(items) > 20:
153
+ click.echo(f" ... and {len(items) - 20} more")
154
+
155
+ sys.exit(0)
156
+
157
+
158
+ @cli.command()
159
+ @click.argument("service")
160
+ @click.argument("resource_type")
161
+ @click.argument("resource_id")
162
+ @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
163
+ @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
164
+ @click.option("--output", type=click.Choice(["json", "table"]), default="json", help="Output format")
165
+ def describe(service, resource_type, resource_id, endpoint, region, output):
166
+ """Describe a specific resource."""
167
+ # Override endpoint/region if provided
168
+ if endpoint != AWS_ENDPOINT_URL:
169
+ import os
170
+
171
+ os.environ["AWS_ENDPOINT_URL"] = endpoint
172
+ if region != AWS_REGION:
173
+ import os
174
+
175
+ os.environ["AWS_REGION"] = region
176
+
177
+ # Special case for WAFv2
178
+ if (service, resource_type) == ("wafv2", "web_acls"):
179
+ try:
180
+ client = get_client("wafv2")
181
+ acls = client.list_web_acls(Scope="REGIONAL").get("WebACLs", [])
182
+ match = next((a for a in acls if a.get("Name") == resource_id), None)
183
+ if not match:
184
+ click.echo(f"Error: Web ACL '{resource_id}' not found", err=True)
185
+ sys.exit(1)
186
+ resp = client.get_web_acl(Name=resource_id, Scope="REGIONAL", Id=match["Id"])
187
+ resp.pop("ResponseMetadata", None)
188
+ detail = _serialize(resp.get("WebACL", resp))
189
+ except Exception as exc:
190
+ click.echo(f"Error describing {service}/{resource_type}/{resource_id}: {exc}", err=True)
191
+ sys.exit(1)
192
+ else:
193
+ lookup = DESCRIBE_REGISTRY.get((service, resource_type))
194
+ if not lookup:
195
+ click.echo(f"Error: No detail lookup registered for {service}/{resource_type}", err=True)
196
+ sys.exit(1)
197
+
198
+ boto3_service, method_name, id_param, response_key = lookup
199
+
200
+ # Some APIs take list parameters
201
+ _LIST_PARAMS = {
202
+ "InstanceIds",
203
+ "VpcIds",
204
+ "SubnetIds",
205
+ "GroupIds",
206
+ "VolumeIds",
207
+ "repositoryNames",
208
+ "clusters",
209
+ "AlarmNames",
210
+ "LoadBalancerArns",
211
+ }
212
+
213
+ try:
214
+ client = get_client(boto3_service)
215
+ method = getattr(client, method_name)
216
+ if id_param in _LIST_PARAMS:
217
+ resp = method(**{id_param: [resource_id]})
218
+ else:
219
+ resp = method(**{id_param: resource_id})
220
+
221
+ resp.pop("ResponseMetadata", None)
222
+
223
+ if response_key is not None:
224
+ detail = resp.get(response_key, resp)
225
+ else:
226
+ detail = resp
227
+
228
+ detail = _serialize(detail)
229
+ except Exception as exc:
230
+ click.echo(f"Error describing {service}/{resource_type}/{resource_id}: {exc}", err=True)
231
+ sys.exit(1)
232
+
233
+ if output == "json":
234
+ click.echo(json.dumps(detail, indent=2))
235
+ else:
236
+ # Table format - simple key-value pairs
237
+ if isinstance(detail, dict):
238
+ for key, value in detail.items():
239
+ click.echo(f"{key}: {value}")
240
+ elif isinstance(detail, list) and len(detail) > 0:
241
+ # If detail is a list, show first item
242
+ for key, value in detail[0].items():
243
+ click.echo(f"{key}: {value}")
244
+ else:
245
+ click.echo(json.dumps(detail, indent=2))
246
+
247
+ sys.exit(0)
248
+
249
+
250
+ @cli.command()
251
+ @click.argument("service")
252
+ @click.option("--endpoint", default=AWS_ENDPOINT_URL, help="AWS endpoint URL", envvar="AWS_ENDPOINT_URL")
253
+ @click.option("--region", default=AWS_REGION, help="AWS region", envvar="AWS_REGION")
254
+ @click.option("--format", "format_", type=click.Choice(["json", "csv"]), default="json", help="Output format")
255
+ @click.pass_context
256
+ def export(ctx, service, endpoint, region, format_):
257
+ """Export all resources for a service."""
258
+ # Reuse list command logic
259
+ ctx.invoke(list, service=service, endpoint=endpoint, region=region, output=format_)
@@ -0,0 +1,79 @@
1
+ import logging
2
+ import os
3
+ from collections.abc import Mapping
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ AWS_ENDPOINT_URL: str = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566")
8
+ AWS_REGION: str = os.environ.get("AWS_REGION", "us-east-1")
9
+ AWS_ACCESS_KEY_ID: str = os.environ.get("AWS_ACCESS_KEY_ID", "test")
10
+ AWS_SECRET_ACCESS_KEY: str = os.environ.get("AWS_SECRET_ACCESS_KEY", "test")
11
+ STACKPORT_PORT: int = int(os.environ.get("STACKPORT_PORT", "8080"))
12
+ STACKPORT_SERVICES: str = os.environ.get(
13
+ "STACKPORT_SERVICES",
14
+ "s3,sqs,sns,dynamodb,lambda,iam,logs,ssm,secretsmanager,kinesis,events,ec2,"
15
+ "route53,kms,cloudformation,stepfunctions,rds,ecs,monitoring,ses,acm,wafv2,"
16
+ "ecr,elasticache,glue,athena,apigateway,firehose,cognito-idp,cognito-identity,"
17
+ "elasticmapreduce,elasticloadbalancing,elasticfilesystem,cloudfront,appsync",
18
+ )
19
+ LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO").upper()
20
+
21
+ _MIB: int = 1024 * 1024
22
+
23
+ # Default max upload: 100 MiB (whole mebibytes; STACKPORT_S3_MAX_UPLOAD_MB).
24
+ _DEFAULT_S3_MAX_UPLOAD_MB: int = 100
25
+ _DEFAULT_S3_MAX_UPLOAD_BYTES: int = _DEFAULT_S3_MAX_UPLOAD_MB * _MIB
26
+
27
+
28
+ def _parse_s3_max_upload_bytes_from_env(environ: Mapping[str, str]) -> int:
29
+ """Resolve max single-object upload size for the S3 write API.
30
+
31
+ Uses ``STACKPORT_S3_MAX_UPLOAD_MB`` only: positive integer **mebibytes** (MiB,
32
+ × 1024²). When unset or empty, default is 100 MiB.
33
+ """
34
+ raw_mb = environ.get("STACKPORT_S3_MAX_UPLOAD_MB")
35
+ if raw_mb is None or not str(raw_mb).strip():
36
+ return _DEFAULT_S3_MAX_UPLOAD_BYTES
37
+ try:
38
+ mb = int(str(raw_mb).strip(), 10)
39
+ except ValueError:
40
+ logger.warning(
41
+ "Invalid STACKPORT_S3_MAX_UPLOAD_MB %r; using default %s MiB",
42
+ raw_mb,
43
+ _DEFAULT_S3_MAX_UPLOAD_MB,
44
+ )
45
+ return _DEFAULT_S3_MAX_UPLOAD_BYTES
46
+ if mb <= 0:
47
+ logger.warning(
48
+ "STACKPORT_S3_MAX_UPLOAD_MB must be positive; using default %s MiB",
49
+ _DEFAULT_S3_MAX_UPLOAD_MB,
50
+ )
51
+ return _DEFAULT_S3_MAX_UPLOAD_BYTES
52
+ return mb * _MIB
53
+
54
+
55
+ def _parse_s3_max_upload_bytes() -> int:
56
+ return _parse_s3_max_upload_bytes_from_env(os.environ)
57
+
58
+
59
+ # Max single-object upload size for S3 write API (configurable; default 100 MiB).
60
+ S3_MAX_UPLOAD_BYTES: int = _parse_s3_max_upload_bytes()
61
+
62
+
63
+ def _parse_endpoints() -> dict[str, str]:
64
+ """Parse STACKPORT_ENDPOINTS env var into dict."""
65
+ endpoints_str = os.environ.get("STACKPORT_ENDPOINTS", "")
66
+ if not endpoints_str:
67
+ # Backward compatibility: single endpoint
68
+ return {"default": AWS_ENDPOINT_URL}
69
+
70
+ endpoints = {}
71
+ for pair in endpoints_str.split(","):
72
+ if "=" in pair:
73
+ name, url = pair.split("=", 1)
74
+ endpoints[name.strip()] = url.strip()
75
+ return endpoints
76
+
77
+
78
+ ENDPOINTS: dict[str, str] = _parse_endpoints()
79
+ DEFAULT_ENDPOINT: str = next(iter(ENDPOINTS.values()))
@@ -1,14 +1,17 @@
1
+ import asyncio
1
2
  import logging
2
3
  import os
4
+ from contextlib import asynccontextmanager
3
5
 
4
6
  import uvicorn
5
- from fastapi import FastAPI
7
+ from fastapi import FastAPI, WebSocket
6
8
  from fastapi.middleware.cors import CORSMiddleware
7
9
  from fastapi.responses import FileResponse
8
10
  from fastapi.staticfiles import StaticFiles
9
11
 
10
12
  from backend.config import LOG_LEVEL, STACKPORT_PORT
11
- from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, sqs, stats
13
+ from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats
14
+ from backend.websocket import probe_loop, websocket_endpoint
12
15
 
13
16
 
14
17
  class HealthcheckFilter(logging.Filter):
@@ -30,7 +33,16 @@ logger = logging.getLogger(__name__)
30
33
  # Suppress noisy healthcheck access logs at non-DEBUG levels
31
34
  logging.getLogger("uvicorn.access").addFilter(HealthcheckFilter())
32
35
 
33
- app = FastAPI(title="StackPort", docs_url="/api/docs")
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ # Startup: launch the WebSocket probe loop
39
+ task = asyncio.create_task(probe_loop())
40
+ yield
41
+ # Shutdown: cancel the background task
42
+ task.cancel()
43
+
44
+
45
+ app = FastAPI(title="StackPort", docs_url="/api/docs", lifespan=lifespan)
34
46
 
35
47
  app.add_middleware(
36
48
  CORSMiddleware,
@@ -40,6 +52,7 @@ app.add_middleware(
40
52
  )
41
53
 
42
54
  app.include_router(stats.router, prefix="/api")
55
+ app.include_router(endpoints.router, prefix="/api")
43
56
  app.include_router(s3.router, prefix="/api/s3")
44
57
  app.include_router(dynamodb.router, prefix="/api/dynamodb")
45
58
  app.include_router(lambda_svc.router, prefix="/api/lambda", tags=["lambda"])
@@ -47,8 +60,16 @@ app.include_router(sqs.router, prefix="/api/sqs", tags=["sqs"])
47
60
  app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
48
61
  app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
49
62
  app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
63
+ app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
50
64
  app.include_router(resources.router, prefix="/api")
51
65
 
66
+
67
+ # WebSocket endpoint for real-time updates
68
+ @app.websocket("/ws")
69
+ async def ws(websocket: WebSocket):
70
+ await websocket_endpoint(websocket)
71
+
72
+
52
73
  # Serve UI static files — mount assets under /assets, SPA fallback for everything else
53
74
  ui_dist = os.path.join(os.path.dirname(__file__), "..", "ui", "dist")
54
75
  if os.path.isdir(ui_dist):
@@ -65,7 +86,10 @@ if os.path.isdir(ui_dist):
65
86
 
66
87
 
67
88
  def cli():
68
- uvicorn.run("backend.main:app", host="0.0.0.0", port=STACKPORT_PORT, log_level=LOG_LEVEL.lower(), reload=False)
89
+ """Entry point for stackport CLI."""
90
+ from backend.cli import cli as click_app
91
+
92
+ click_app()
69
93
 
70
94
 
71
95
  if __name__ == "__main__":
@@ -0,0 +1,25 @@
1
+ """Common route dependencies and utilities."""
2
+
3
+ from fastapi import Query
4
+
5
+ from backend.config import DEFAULT_ENDPOINT, ENDPOINTS
6
+
7
+
8
+ def get_endpoint_url(endpoint: str | None = Query(None, description="Endpoint name or URL")) -> str:
9
+ """Extract and validate endpoint from query params.
10
+
11
+ Args:
12
+ endpoint: Endpoint name (e.g., "local") or direct URL. If None, uses default.
13
+
14
+ Returns:
15
+ Endpoint URL to use for AWS API calls
16
+ """
17
+ if endpoint is None:
18
+ return DEFAULT_ENDPOINT
19
+
20
+ # Check if it's a known endpoint name
21
+ if endpoint in ENDPOINTS:
22
+ return ENDPOINTS[endpoint]
23
+
24
+ # Otherwise treat as direct URL (for flexibility)
25
+ return endpoint
@@ -0,0 +1,33 @@
1
+ """Endpoints management routes."""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from backend.aws_client import get_client
8
+ from backend.config import ENDPOINTS
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get("/endpoints")
16
+ def list_endpoints():
17
+ """List configured endpoints with health status."""
18
+ results = []
19
+
20
+ for name, url in ENDPOINTS.items():
21
+ health = "unknown"
22
+ try:
23
+ # Quick health check - try to list S3 buckets
24
+ s3 = get_client("s3", url)
25
+ s3.list_buckets()
26
+ health = "healthy"
27
+ except Exception:
28
+ logger.debug("Endpoint %s (%s) unhealthy", name, url, exc_info=True)
29
+ health = "unhealthy"
30
+
31
+ results.append({"name": name, "url": url, "health": health})
32
+
33
+ return {"endpoints": results}