stackport 0.1.8__tar.gz → 0.2.0__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.1.8/stackport.egg-info → stackport-0.2.0}/PKG-INFO +25 -5
- {stackport-0.1.8 → stackport-0.2.0}/README.md +21 -4
- stackport-0.2.0/backend/aws_client.py +31 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/cache.py +11 -2
- stackport-0.2.0/backend/cli.py +259 -0
- stackport-0.2.0/backend/config.py +84 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/main.py +28 -4
- stackport-0.2.0/backend/routes/common.py +25 -0
- stackport-0.2.0/backend/routes/endpoints.py +33 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/resources.py +9 -8
- stackport-0.2.0/backend/routes/s3.py +356 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/stats.py +16 -9
- stackport-0.2.0/backend/routes/tags.py +549 -0
- stackport-0.2.0/backend/websocket.py +110 -0
- {stackport-0.1.8 → stackport-0.2.0}/pyproject.toml +9 -2
- {stackport-0.1.8 → stackport-0.2.0/stackport.egg-info}/PKG-INFO +25 -5
- {stackport-0.1.8 → stackport-0.2.0}/stackport.egg-info/SOURCES.txt +15 -4
- {stackport-0.1.8 → stackport-0.2.0}/stackport.egg-info/requires.txt +3 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_cache.py +11 -0
- stackport-0.2.0/tests/test_cli.py +122 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_config.py +30 -0
- stackport-0.2.0/tests/test_endpoints.py +98 -0
- stackport-0.2.0/tests/test_s3_routes.py +353 -0
- stackport-0.2.0/tests/test_s3_upload_limit_env.py +38 -0
- stackport-0.2.0/tests/test_tags_routes.py +826 -0
- stackport-0.2.0/tests/test_websocket.py +58 -0
- stackport-0.2.0/ui/dist/assets/index-BauDt01Y.css +1 -0
- stackport-0.2.0/ui/dist/assets/index-CMbP0j4Q.js +563 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/index.html +2 -2
- stackport-0.1.8/backend/aws_client.py +0 -22
- stackport-0.1.8/backend/config.py +0 -15
- stackport-0.1.8/backend/routes/s3.py +0 -169
- stackport-0.1.8/ui/dist/assets/index-BAMXqPoR.js +0 -537
- stackport-0.1.8/ui/dist/assets/index-CU1gzmix.css +0 -1
- {stackport-0.1.8 → stackport-0.2.0}/LICENSE +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/MANIFEST.in +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/__init__.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/__init__.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/dynamodb.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/ec2.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/iam.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/logs.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/backend/routes/sqs.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/setup.cfg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_client.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_ec2_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_iam_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_lambda_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_logs_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_registries.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/tests/test_sqs_routes.py +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/favicon.png +0 -0
- {stackport-0.1.8 → stackport-0.2.0}/ui/dist/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
-

|
|
54
57
|
|
|
55
58
|
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
56
|
-

|
|
57
60
|
|
|
58
61
|
**S3 Browser** — File browser with folder navigation and object preview
|
|
59
|
-

|
|
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
|
-

|
|
22
22
|
|
|
23
23
|
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
24
|
-

|
|
25
25
|
|
|
26
26
|
**S3 Browser** — File browser with folder navigation and object preview
|
|
27
|
-

|
|
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
|
+
)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
import time
|
|
3
3
|
|
|
4
|
+
from backend.config import STACKPORT_CACHE_TTL
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
class TTLCache:
|
|
6
|
-
def __init__(self):
|
|
8
|
+
def __init__(self, default_ttl: int = STACKPORT_CACHE_TTL):
|
|
7
9
|
self._store: dict = {}
|
|
8
10
|
self._lock = threading.Lock()
|
|
11
|
+
self._default_ttl = default_ttl
|
|
9
12
|
|
|
10
13
|
def get(self, key: str):
|
|
11
14
|
with self._lock:
|
|
@@ -16,9 +19,15 @@ class TTLCache:
|
|
|
16
19
|
del self._store[key]
|
|
17
20
|
return None
|
|
18
21
|
|
|
19
|
-
def set(self, key: str, value, ttl: float =
|
|
22
|
+
def set(self, key: str, value, ttl: float | None = None):
|
|
23
|
+
if ttl is None:
|
|
24
|
+
ttl = self._default_ttl
|
|
20
25
|
with self._lock:
|
|
21
26
|
self._store[key] = (value, time.time() + ttl)
|
|
22
27
|
|
|
28
|
+
def delete(self, key: str) -> None:
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._store.pop(key, None)
|
|
31
|
+
|
|
23
32
|
|
|
24
33
|
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,84 @@
|
|
|
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
|
+
# Probe and cache configuration
|
|
22
|
+
STACKPORT_PROBE_TIMEOUT: int = int(os.environ.get("STACKPORT_PROBE_TIMEOUT", "5"))
|
|
23
|
+
STACKPORT_CACHE_TTL: int = int(os.environ.get("STACKPORT_CACHE_TTL", "5"))
|
|
24
|
+
STACKPORT_PROBE_WORKERS: int = int(os.environ.get("STACKPORT_PROBE_WORKERS", "10"))
|
|
25
|
+
|
|
26
|
+
_MIB: int = 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# Default max upload: 100 MiB (whole mebibytes; STACKPORT_S3_MAX_UPLOAD_MB).
|
|
29
|
+
_DEFAULT_S3_MAX_UPLOAD_MB: int = 100
|
|
30
|
+
_DEFAULT_S3_MAX_UPLOAD_BYTES: int = _DEFAULT_S3_MAX_UPLOAD_MB * _MIB
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_s3_max_upload_bytes_from_env(environ: Mapping[str, str]) -> int:
|
|
34
|
+
"""Resolve max single-object upload size for the S3 write API.
|
|
35
|
+
|
|
36
|
+
Uses ``STACKPORT_S3_MAX_UPLOAD_MB`` only: positive integer **mebibytes** (MiB,
|
|
37
|
+
× 1024²). When unset or empty, default is 100 MiB.
|
|
38
|
+
"""
|
|
39
|
+
raw_mb = environ.get("STACKPORT_S3_MAX_UPLOAD_MB")
|
|
40
|
+
if raw_mb is None or not str(raw_mb).strip():
|
|
41
|
+
return _DEFAULT_S3_MAX_UPLOAD_BYTES
|
|
42
|
+
try:
|
|
43
|
+
mb = int(str(raw_mb).strip(), 10)
|
|
44
|
+
except ValueError:
|
|
45
|
+
logger.warning(
|
|
46
|
+
"Invalid STACKPORT_S3_MAX_UPLOAD_MB %r; using default %s MiB",
|
|
47
|
+
raw_mb,
|
|
48
|
+
_DEFAULT_S3_MAX_UPLOAD_MB,
|
|
49
|
+
)
|
|
50
|
+
return _DEFAULT_S3_MAX_UPLOAD_BYTES
|
|
51
|
+
if mb <= 0:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"STACKPORT_S3_MAX_UPLOAD_MB must be positive; using default %s MiB",
|
|
54
|
+
_DEFAULT_S3_MAX_UPLOAD_MB,
|
|
55
|
+
)
|
|
56
|
+
return _DEFAULT_S3_MAX_UPLOAD_BYTES
|
|
57
|
+
return mb * _MIB
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_s3_max_upload_bytes() -> int:
|
|
61
|
+
return _parse_s3_max_upload_bytes_from_env(os.environ)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Max single-object upload size for S3 write API (configurable; default 100 MiB).
|
|
65
|
+
S3_MAX_UPLOAD_BYTES: int = _parse_s3_max_upload_bytes()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_endpoints() -> dict[str, str]:
|
|
69
|
+
"""Parse STACKPORT_ENDPOINTS env var into dict."""
|
|
70
|
+
endpoints_str = os.environ.get("STACKPORT_ENDPOINTS", "")
|
|
71
|
+
if not endpoints_str:
|
|
72
|
+
# Backward compatibility: single endpoint
|
|
73
|
+
return {"default": AWS_ENDPOINT_URL}
|
|
74
|
+
|
|
75
|
+
endpoints = {}
|
|
76
|
+
for pair in endpoints_str.split(","):
|
|
77
|
+
if "=" in pair:
|
|
78
|
+
name, url = pair.split("=", 1)
|
|
79
|
+
endpoints[name.strip()] = url.strip()
|
|
80
|
+
return endpoints
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
ENDPOINTS: dict[str, str] = _parse_endpoints()
|
|
84
|
+
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, secretsmanager, sqs, stats
|
|
13
|
+
from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats, tags
|
|
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
|
-
|
|
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"])
|
|
@@ -48,8 +61,16 @@ 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"])
|
|
50
63
|
app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
|
|
64
|
+
app.include_router(tags.router, prefix="/api", tags=["tags"])
|
|
51
65
|
app.include_router(resources.router, prefix="/api")
|
|
52
66
|
|
|
67
|
+
|
|
68
|
+
# WebSocket endpoint for real-time updates
|
|
69
|
+
@app.websocket("/ws")
|
|
70
|
+
async def ws(websocket: WebSocket):
|
|
71
|
+
await websocket_endpoint(websocket)
|
|
72
|
+
|
|
73
|
+
|
|
53
74
|
# Serve UI static files — mount assets under /assets, SPA fallback for everything else
|
|
54
75
|
ui_dist = os.path.join(os.path.dirname(__file__), "..", "ui", "dist")
|
|
55
76
|
if os.path.isdir(ui_dist):
|
|
@@ -66,7 +87,10 @@ if os.path.isdir(ui_dist):
|
|
|
66
87
|
|
|
67
88
|
|
|
68
89
|
def cli():
|
|
69
|
-
|
|
90
|
+
"""Entry point for stackport CLI."""
|
|
91
|
+
from backend.cli import cli as click_app
|
|
92
|
+
|
|
93
|
+
click_app()
|
|
70
94
|
|
|
71
95
|
|
|
72
96
|
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}
|