stackport 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stackport-0.1.2/stackport.egg-info → stackport-0.1.3}/PKG-INFO +24 -8
- {stackport-0.1.2 → stackport-0.1.3}/README.md +19 -7
- {stackport-0.1.2 → stackport-0.1.3}/backend/main.py +2 -1
- stackport-0.1.3/backend/routes/dynamodb.py +218 -0
- {stackport-0.1.2 → stackport-0.1.3}/pyproject.toml +8 -1
- {stackport-0.1.2 → stackport-0.1.3/stackport.egg-info}/PKG-INFO +24 -8
- {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/SOURCES.txt +11 -4
- stackport-0.1.3/stackport.egg-info/requires.txt +8 -0
- stackport-0.1.3/tests/test_cache.py +63 -0
- stackport-0.1.3/tests/test_client.py +17 -0
- stackport-0.1.3/tests/test_config.py +41 -0
- stackport-0.1.3/tests/test_dynamodb_routes.py +255 -0
- stackport-0.1.3/tests/test_registries.py +69 -0
- stackport-0.1.3/tests/test_routes.py +117 -0
- stackport-0.1.3/ui/dist/assets/index-CEFYqXru.css +1 -0
- stackport-0.1.3/ui/dist/assets/index-DyyngEdQ.js +370 -0
- {stackport-0.1.2 → stackport-0.1.3}/ui/dist/index.html +2 -2
- stackport-0.1.2/stackport.egg-info/requires.txt +0 -3
- stackport-0.1.2/ui/dist/assets/index-CRqBg0t6.js +0 -360
- stackport-0.1.2/ui/dist/assets/index-Is3dIbZM.css +0 -1
- {stackport-0.1.2 → stackport-0.1.3}/LICENSE +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/MANIFEST.in +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/__init__.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/aws_client.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/cache.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/config.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/routes/__init__.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/routes/resources.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/routes/s3.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/backend/routes/stats.py +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/setup.cfg +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.1.2 → stackport-0.1.3}/ui/dist/favicon.png +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -24,19 +24,33 @@ 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
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
30
|
+
Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
|
|
27
31
|
Dynamic: license-file
|
|
28
32
|
|
|
29
33
|
# StackPort
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
<p>
|
|
36
|
+
<a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
37
|
+
<a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
|
|
38
|
+
<a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
|
|
39
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
40
|
+
</p>
|
|
35
41
|
|
|
36
42
|
Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
## Screenshots
|
|
45
|
+
|
|
46
|
+
**Dashboard** — Service overview with resource counts and health status
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
**S3 Browser** — File browser with folder navigation and object preview
|
|
53
|
+

|
|
40
54
|
|
|
41
55
|
## Features
|
|
42
56
|
|
|
@@ -62,8 +76,10 @@ stackport
|
|
|
62
76
|
|
|
63
77
|
### Docker Compose (MiniStack + StackPort)
|
|
64
78
|
|
|
79
|
+
This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
|
|
80
|
+
|
|
65
81
|
```bash
|
|
66
|
-
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
|
|
82
|
+
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
|
|
67
83
|
docker compose up -d
|
|
68
84
|
# Open http://localhost:8080
|
|
69
85
|
```
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# StackPort
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<p>
|
|
4
|
+
<a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
5
|
+
<a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
|
|
6
|
+
<a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
|
|
7
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
8
|
+
</p>
|
|
7
9
|
|
|
8
10
|
Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
## Screenshots
|
|
13
|
+
|
|
14
|
+
**Dashboard** — Service overview with resource counts and health status
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
**S3 Browser** — File browser with folder navigation and object preview
|
|
21
|
+

|
|
12
22
|
|
|
13
23
|
## Features
|
|
14
24
|
|
|
@@ -34,8 +44,10 @@ stackport
|
|
|
34
44
|
|
|
35
45
|
### Docker Compose (MiniStack + StackPort)
|
|
36
46
|
|
|
47
|
+
This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
|
|
48
|
+
|
|
37
49
|
```bash
|
|
38
|
-
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
|
|
50
|
+
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
|
|
39
51
|
docker compose up -d
|
|
40
52
|
# Open http://localhost:8080
|
|
41
53
|
```
|
|
@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
|
|
|
9
9
|
from fastapi.staticfiles import StaticFiles
|
|
10
10
|
|
|
11
11
|
from backend.config import STACKPORT_PORT
|
|
12
|
-
from backend.routes import resources, s3, stats
|
|
12
|
+
from backend.routes import dynamodb, resources, s3, stats
|
|
13
13
|
|
|
14
14
|
logging.basicConfig(
|
|
15
15
|
level=logging.INFO,
|
|
@@ -28,6 +28,7 @@ app.add_middleware(
|
|
|
28
28
|
|
|
29
29
|
app.include_router(stats.router, prefix="/api")
|
|
30
30
|
app.include_router(s3.router, prefix="/api/s3")
|
|
31
|
+
app.include_router(dynamodb.router, prefix="/api/dynamodb")
|
|
31
32
|
app.include_router(resources.router, prefix="/api")
|
|
32
33
|
|
|
33
34
|
# Serve UI static files — mount assets under /assets, SPA fallback for everything else
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Query
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from backend.aws_client import get_client
|
|
8
|
+
from backend.cache import cache
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_table_item_count(table_name: str) -> int:
|
|
16
|
+
"""Return item count for a table. Cached 30s."""
|
|
17
|
+
cache_key = f"dynamodb:item_count:{table_name}"
|
|
18
|
+
cached = cache.get(cache_key)
|
|
19
|
+
if cached is not None:
|
|
20
|
+
return cached
|
|
21
|
+
|
|
22
|
+
dynamodb = get_client("dynamodb")
|
|
23
|
+
try:
|
|
24
|
+
resp = dynamodb.describe_table(TableName=table_name)
|
|
25
|
+
item_count = resp["Table"].get("ItemCount", 0)
|
|
26
|
+
cache.set(cache_key, item_count, ttl=30)
|
|
27
|
+
return item_count
|
|
28
|
+
except Exception:
|
|
29
|
+
logger.debug("Failed to get item count for %s", table_name, exc_info=True)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/tables")
|
|
34
|
+
def list_tables():
|
|
35
|
+
dynamodb = get_client("dynamodb")
|
|
36
|
+
paginator = dynamodb.get_paginator("list_tables")
|
|
37
|
+
table_names = []
|
|
38
|
+
|
|
39
|
+
for page in paginator.paginate():
|
|
40
|
+
table_names.extend(page.get("TableNames", []))
|
|
41
|
+
|
|
42
|
+
tables = []
|
|
43
|
+
for name in table_names:
|
|
44
|
+
try:
|
|
45
|
+
resp = dynamodb.describe_table(TableName=name)
|
|
46
|
+
table = resp["Table"]
|
|
47
|
+
item_count = table.get("ItemCount", 0)
|
|
48
|
+
table_size = table.get("TableSizeBytes", 0)
|
|
49
|
+
|
|
50
|
+
key_schema = table.get("KeySchema", [])
|
|
51
|
+
partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
|
|
52
|
+
sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
|
|
53
|
+
|
|
54
|
+
tables.append(
|
|
55
|
+
{
|
|
56
|
+
"name": name,
|
|
57
|
+
"status": table.get("TableStatus", "UNKNOWN"),
|
|
58
|
+
"item_count": item_count,
|
|
59
|
+
"size_bytes": table_size,
|
|
60
|
+
"partition_key": partition_key,
|
|
61
|
+
"sort_key": sort_key,
|
|
62
|
+
"billing_mode": table.get("BillingModeSummary", {}).get("BillingMode", "PROVISIONED"),
|
|
63
|
+
"created": table.get("CreationDateTime").isoformat() if table.get("CreationDateTime") else None,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.debug("Failed to describe table %s", name, exc_info=True)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
return {"tables": tables}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/tables/{name}")
|
|
74
|
+
def get_table_detail(name: str):
|
|
75
|
+
dynamodb = get_client("dynamodb")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
resp = dynamodb.describe_table(TableName=name)
|
|
79
|
+
table = resp["Table"]
|
|
80
|
+
|
|
81
|
+
key_schema = table.get("KeySchema", [])
|
|
82
|
+
attribute_defs = {attr["AttributeName"]: attr["AttributeType"] for attr in table.get("AttributeDefinitions", [])}
|
|
83
|
+
|
|
84
|
+
partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
|
|
85
|
+
sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"name": name,
|
|
89
|
+
"status": table.get("TableStatus", "UNKNOWN"),
|
|
90
|
+
"item_count": table.get("ItemCount", 0),
|
|
91
|
+
"size_bytes": table.get("TableSizeBytes", 0),
|
|
92
|
+
"partition_key": partition_key,
|
|
93
|
+
"partition_key_type": attribute_defs.get(partition_key) if partition_key else None,
|
|
94
|
+
"sort_key": sort_key,
|
|
95
|
+
"sort_key_type": attribute_defs.get(sort_key) if sort_key else None,
|
|
96
|
+
"billing_mode": table.get("BillingModeSummary", {}).get("BillingMode", "PROVISIONED"),
|
|
97
|
+
"created": table.get("CreationDateTime").isoformat() if table.get("CreationDateTime") else None,
|
|
98
|
+
"attribute_definitions": attribute_defs,
|
|
99
|
+
"key_schema": key_schema,
|
|
100
|
+
"global_secondary_indexes": table.get("GlobalSecondaryIndexes", []),
|
|
101
|
+
"local_secondary_indexes": table.get("LocalSecondaryIndexes", []),
|
|
102
|
+
}
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error("Failed to get table detail for %s: %s", name, e, exc_info=True)
|
|
105
|
+
return {"error": str(e)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/tables/{name}/items")
|
|
109
|
+
def scan_table(
|
|
110
|
+
name: str,
|
|
111
|
+
limit: int = Query(default=25, ge=1, le=100, description="Max items per page"),
|
|
112
|
+
exclusive_start_key: str = Query(default=None, description="Base64 encoded last evaluated key for pagination"),
|
|
113
|
+
):
|
|
114
|
+
dynamodb = get_client("dynamodb")
|
|
115
|
+
|
|
116
|
+
scan_params: dict[str, Any] = {
|
|
117
|
+
"TableName": name,
|
|
118
|
+
"Limit": limit,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if exclusive_start_key:
|
|
122
|
+
import base64
|
|
123
|
+
import json
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
decoded = base64.b64decode(exclusive_start_key).decode("utf-8")
|
|
127
|
+
scan_params["ExclusiveStartKey"] = json.loads(decoded)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.debug("Invalid exclusive_start_key", exc_info=True)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
resp = dynamodb.scan(**scan_params)
|
|
133
|
+
items = resp.get("Items", [])
|
|
134
|
+
|
|
135
|
+
last_evaluated_key = resp.get("LastEvaluatedKey")
|
|
136
|
+
next_token = None
|
|
137
|
+
if last_evaluated_key:
|
|
138
|
+
import base64
|
|
139
|
+
import json
|
|
140
|
+
|
|
141
|
+
next_token = base64.b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf-8")
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"table": name,
|
|
145
|
+
"items": items,
|
|
146
|
+
"count": len(items),
|
|
147
|
+
"scanned_count": resp.get("ScannedCount", len(items)),
|
|
148
|
+
"next_token": next_token,
|
|
149
|
+
}
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error("Failed to scan table %s: %s", name, e, exc_info=True)
|
|
152
|
+
return {"error": str(e), "items": [], "count": 0}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class QueryRequest(BaseModel):
|
|
156
|
+
partition_key_value: str
|
|
157
|
+
sort_key_value: str | None = None
|
|
158
|
+
sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
|
|
159
|
+
limit: int = 25
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/tables/{name}/query")
|
|
163
|
+
def query_table(name: str, request: QueryRequest):
|
|
164
|
+
dynamodb = get_client("dynamodb")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
# Get table key schema
|
|
168
|
+
table_resp = dynamodb.describe_table(TableName=name)
|
|
169
|
+
table = table_resp["Table"]
|
|
170
|
+
key_schema = table.get("KeySchema", [])
|
|
171
|
+
attribute_defs = {attr["AttributeName"]: attr["AttributeType"] for attr in table.get("AttributeDefinitions", [])}
|
|
172
|
+
|
|
173
|
+
partition_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "HASH"), None)
|
|
174
|
+
sort_key = next((k["AttributeName"] for k in key_schema if k["KeyType"] == "RANGE"), None)
|
|
175
|
+
|
|
176
|
+
if not partition_key:
|
|
177
|
+
return {"error": "No partition key found in table schema", "items": [], "count": 0}
|
|
178
|
+
|
|
179
|
+
partition_key_type = attribute_defs.get(partition_key, "S")
|
|
180
|
+
|
|
181
|
+
# Build key condition expression
|
|
182
|
+
key_condition = f"#{partition_key} = :pk"
|
|
183
|
+
expression_attr_names = {f"#{partition_key}": partition_key}
|
|
184
|
+
expression_attr_values = {":pk": {partition_key_type: request.partition_key_value}}
|
|
185
|
+
|
|
186
|
+
if sort_key and request.sort_key_value:
|
|
187
|
+
sort_key_type = attribute_defs.get(sort_key, "S")
|
|
188
|
+
if request.sort_key_operator == "=":
|
|
189
|
+
key_condition += f" AND #{sort_key} = :sk"
|
|
190
|
+
expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
|
|
191
|
+
elif request.sort_key_operator in ("<", "<=", ">", ">="):
|
|
192
|
+
key_condition += f" AND #{sort_key} {request.sort_key_operator} :sk"
|
|
193
|
+
expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
|
|
194
|
+
elif request.sort_key_operator == "BEGINS_WITH":
|
|
195
|
+
key_condition += f" AND begins_with(#{sort_key}, :sk)"
|
|
196
|
+
expression_attr_values[":sk"] = {sort_key_type: request.sort_key_value}
|
|
197
|
+
expression_attr_names[f"#{sort_key}"] = sort_key
|
|
198
|
+
|
|
199
|
+
query_params = {
|
|
200
|
+
"TableName": name,
|
|
201
|
+
"KeyConditionExpression": key_condition,
|
|
202
|
+
"ExpressionAttributeNames": expression_attr_names,
|
|
203
|
+
"ExpressionAttributeValues": expression_attr_values,
|
|
204
|
+
"Limit": request.limit,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
resp = dynamodb.query(**query_params)
|
|
208
|
+
items = resp.get("Items", [])
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"table": name,
|
|
212
|
+
"items": items,
|
|
213
|
+
"count": len(items),
|
|
214
|
+
"scanned_count": resp.get("ScannedCount", len(items)),
|
|
215
|
+
}
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error("Failed to query table %s: %s", name, e, exc_info=True)
|
|
218
|
+
return {"error": str(e), "items": [], "count": 0}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackport"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Universal AWS resource browser for local emulators"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -26,6 +26,13 @@ Homepage = "https://github.com/DaviReisVieira/stackport"
|
|
|
26
26
|
Repository = "https://github.com/DaviReisVieira/stackport"
|
|
27
27
|
Issues = "https://github.com/DaviReisVieira/stackport/issues"
|
|
28
28
|
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8.0", "httpx>=0.27.0", "moto[s3,sqs,lambda,dynamodb,iam]>=5.0"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["."]
|
|
35
|
+
|
|
29
36
|
[build-system]
|
|
30
37
|
requires = ["setuptools>=68.0"]
|
|
31
38
|
build-backend = "setuptools.build_meta"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -24,19 +24,33 @@ 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
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
30
|
+
Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
|
|
27
31
|
Dynamic: license-file
|
|
28
32
|
|
|
29
33
|
# StackPort
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
<p>
|
|
36
|
+
<a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
37
|
+
<a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
|
|
38
|
+
<a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
|
|
39
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
40
|
+
</p>
|
|
35
41
|
|
|
36
42
|
Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
## Screenshots
|
|
45
|
+
|
|
46
|
+
**Dashboard** — Service overview with resource counts and health status
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
**S3 Browser** — File browser with folder navigation and object preview
|
|
53
|
+

|
|
40
54
|
|
|
41
55
|
## Features
|
|
42
56
|
|
|
@@ -62,8 +76,10 @@ stackport
|
|
|
62
76
|
|
|
63
77
|
### Docker Compose (MiniStack + StackPort)
|
|
64
78
|
|
|
79
|
+
This example uses [MiniStack](https://github.com/Nahuel990/ministack) as the emulator, but you can swap it for LocalStack, Moto, or any AWS-compatible endpoint — just update `AWS_ENDPOINT_URL`.
|
|
80
|
+
|
|
65
81
|
```bash
|
|
66
|
-
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/docker-compose.yml
|
|
82
|
+
curl -O https://raw.githubusercontent.com/DaviReisVieira/stackport/main/examples/docker-compose.yml
|
|
67
83
|
docker compose up -d
|
|
68
84
|
# Open http://localhost:8080
|
|
69
85
|
```
|
|
@@ -9,9 +9,10 @@ backend/config.py
|
|
|
9
9
|
backend/main.py
|
|
10
10
|
backend/../ui/dist/favicon.png
|
|
11
11
|
backend/../ui/dist/index.html
|
|
12
|
-
backend/../ui/dist/assets/index-
|
|
13
|
-
backend/../ui/dist/assets/index-
|
|
12
|
+
backend/../ui/dist/assets/index-CEFYqXru.css
|
|
13
|
+
backend/../ui/dist/assets/index-DyyngEdQ.js
|
|
14
14
|
backend/routes/__init__.py
|
|
15
|
+
backend/routes/dynamodb.py
|
|
15
16
|
backend/routes/resources.py
|
|
16
17
|
backend/routes/s3.py
|
|
17
18
|
backend/routes/stats.py
|
|
@@ -21,7 +22,13 @@ stackport.egg-info/dependency_links.txt
|
|
|
21
22
|
stackport.egg-info/entry_points.txt
|
|
22
23
|
stackport.egg-info/requires.txt
|
|
23
24
|
stackport.egg-info/top_level.txt
|
|
25
|
+
tests/test_cache.py
|
|
26
|
+
tests/test_client.py
|
|
27
|
+
tests/test_config.py
|
|
28
|
+
tests/test_dynamodb_routes.py
|
|
29
|
+
tests/test_registries.py
|
|
30
|
+
tests/test_routes.py
|
|
24
31
|
ui/dist/favicon.png
|
|
25
32
|
ui/dist/index.html
|
|
26
|
-
ui/dist/assets/index-
|
|
27
|
-
ui/dist/assets/index-
|
|
33
|
+
ui/dist/assets/index-CEFYqXru.css
|
|
34
|
+
ui/dist/assets/index-DyyngEdQ.js
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from backend.cache import TTLCache
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestTTLCache:
|
|
8
|
+
def test_set_and_get(self):
|
|
9
|
+
c = TTLCache()
|
|
10
|
+
c.set("k", "v", ttl=10)
|
|
11
|
+
assert c.get("k") == "v"
|
|
12
|
+
|
|
13
|
+
def test_get_missing_key(self):
|
|
14
|
+
c = TTLCache()
|
|
15
|
+
assert c.get("missing") is None
|
|
16
|
+
|
|
17
|
+
def test_expiry(self):
|
|
18
|
+
c = TTLCache()
|
|
19
|
+
c.set("k", "v", ttl=0.1)
|
|
20
|
+
time.sleep(0.15)
|
|
21
|
+
assert c.get("k") is None
|
|
22
|
+
|
|
23
|
+
def test_overwrite(self):
|
|
24
|
+
c = TTLCache()
|
|
25
|
+
c.set("k", "v1", ttl=10)
|
|
26
|
+
c.set("k", "v2", ttl=10)
|
|
27
|
+
assert c.get("k") == "v2"
|
|
28
|
+
|
|
29
|
+
def test_thread_safety(self):
|
|
30
|
+
c = TTLCache()
|
|
31
|
+
errors = []
|
|
32
|
+
|
|
33
|
+
def writer(n: int):
|
|
34
|
+
try:
|
|
35
|
+
for i in range(100):
|
|
36
|
+
c.set(f"key-{n}-{i}", i, ttl=5)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
errors.append(e)
|
|
39
|
+
|
|
40
|
+
def reader():
|
|
41
|
+
try:
|
|
42
|
+
for _ in range(100):
|
|
43
|
+
c.get("key-0-0")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
errors.append(e)
|
|
46
|
+
|
|
47
|
+
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
|
|
48
|
+
threads += [threading.Thread(target=reader) for _ in range(5)]
|
|
49
|
+
for t in threads:
|
|
50
|
+
t.start()
|
|
51
|
+
for t in threads:
|
|
52
|
+
t.join()
|
|
53
|
+
|
|
54
|
+
assert errors == []
|
|
55
|
+
|
|
56
|
+
def test_different_types(self):
|
|
57
|
+
c = TTLCache()
|
|
58
|
+
c.set("dict", {"a": 1}, ttl=10)
|
|
59
|
+
c.set("list", [1, 2, 3], ttl=10)
|
|
60
|
+
c.set("int", 42, ttl=10)
|
|
61
|
+
assert c.get("dict") == {"a": 1}
|
|
62
|
+
assert c.get("list") == [1, 2, 3]
|
|
63
|
+
assert c.get("int") == 42
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from backend.aws_client import get_client
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestGetClient:
|
|
5
|
+
def test_returns_boto3_client(self):
|
|
6
|
+
client = get_client("s3")
|
|
7
|
+
assert hasattr(client, "list_buckets")
|
|
8
|
+
|
|
9
|
+
def test_lru_cache_returns_same_instance(self):
|
|
10
|
+
c1 = get_client("s3")
|
|
11
|
+
c2 = get_client("s3")
|
|
12
|
+
assert c1 is c2
|
|
13
|
+
|
|
14
|
+
def test_different_services_return_different_clients(self):
|
|
15
|
+
s3 = get_client("s3")
|
|
16
|
+
sqs = get_client("sqs")
|
|
17
|
+
assert s3 is not sqs
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestConfig:
|
|
5
|
+
def test_defaults(self):
|
|
6
|
+
"""Config module provides sensible defaults."""
|
|
7
|
+
from backend.config import (
|
|
8
|
+
AWS_ACCESS_KEY_ID,
|
|
9
|
+
AWS_ENDPOINT_URL,
|
|
10
|
+
AWS_REGION,
|
|
11
|
+
AWS_SECRET_ACCESS_KEY,
|
|
12
|
+
STACKPORT_PORT,
|
|
13
|
+
STACKPORT_SERVICES,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
assert AWS_ENDPOINT_URL # non-empty
|
|
17
|
+
assert AWS_REGION # non-empty
|
|
18
|
+
assert AWS_ACCESS_KEY_ID # non-empty
|
|
19
|
+
assert AWS_SECRET_ACCESS_KEY # non-empty
|
|
20
|
+
assert isinstance(STACKPORT_PORT, int)
|
|
21
|
+
assert STACKPORT_PORT > 0
|
|
22
|
+
# Services string should contain known services
|
|
23
|
+
services = [s.strip() for s in STACKPORT_SERVICES.split(",")]
|
|
24
|
+
assert "s3" in services
|
|
25
|
+
assert "dynamodb" in services
|
|
26
|
+
assert "lambda" in services
|
|
27
|
+
assert len(services) >= 30 # at least 30 services configured
|
|
28
|
+
|
|
29
|
+
def test_env_override(self, monkeypatch):
|
|
30
|
+
"""Config respects environment variable overrides."""
|
|
31
|
+
monkeypatch.setenv("STACKPORT_PORT", "9999")
|
|
32
|
+
# Re-import to pick up env change
|
|
33
|
+
import importlib
|
|
34
|
+
|
|
35
|
+
import backend.config
|
|
36
|
+
|
|
37
|
+
importlib.reload(backend.config)
|
|
38
|
+
assert backend.config.STACKPORT_PORT == 9999
|
|
39
|
+
# Restore
|
|
40
|
+
monkeypatch.delenv("STACKPORT_PORT")
|
|
41
|
+
importlib.reload(backend.config)
|