stackport 0.2.6__tar.gz → 0.3.1__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 (109) hide show
  1. {stackport-0.2.6/stackport.egg-info → stackport-0.3.1}/PKG-INFO +32 -3
  2. {stackport-0.2.6 → stackport-0.3.1}/README.md +31 -2
  3. stackport-0.3.1/backend/aws_client.py +63 -0
  4. {stackport-0.2.6 → stackport-0.3.1}/backend/cache.py +12 -0
  5. {stackport-0.2.6 → stackport-0.3.1}/backend/config.py +17 -0
  6. stackport-0.3.1/backend/endpoint_store.py +409 -0
  7. {stackport-0.2.6 → stackport-0.3.1}/backend/main.py +24 -1
  8. stackport-0.3.1/backend/routes/common.py +63 -0
  9. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/dynamodb.py +18 -18
  10. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/ec2.py +19 -19
  11. stackport-0.3.1/backend/routes/endpoints.py +297 -0
  12. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/iam.py +17 -17
  13. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/lambda_svc.py +15 -15
  14. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/logs.py +9 -9
  15. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/resources.py +10 -10
  16. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/s3.py +44 -44
  17. stackport-0.3.1/backend/routes/secretsmanager.py +256 -0
  18. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/sqs.py +25 -25
  19. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/stats.py +16 -10
  20. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/tags.py +10 -10
  21. stackport-0.3.1/backend/schemas/endpoints.py +38 -0
  22. stackport-0.3.1/backend/schemas/secretsmanager.py +33 -0
  23. {stackport-0.2.6 → stackport-0.3.1}/backend/websocket.py +42 -8
  24. {stackport-0.2.6 → stackport-0.3.1}/pyproject.toml +1 -1
  25. {stackport-0.2.6 → stackport-0.3.1/stackport.egg-info}/PKG-INFO +32 -3
  26. {stackport-0.2.6 → stackport-0.3.1}/stackport.egg-info/SOURCES.txt +8 -4
  27. stackport-0.3.1/tests/test_client.py +34 -0
  28. stackport-0.3.1/tests/test_endpoint_store.py +516 -0
  29. stackport-0.3.1/tests/test_endpoints.py +460 -0
  30. {stackport-0.2.6 → stackport-0.3.1}/tests/test_multi_endpoint.py +26 -24
  31. {stackport-0.2.6 → stackport-0.3.1}/tests/test_secretsmanager_routes.py +227 -0
  32. stackport-0.3.1/ui/dist/assets/index-CAZXHF4B.js +639 -0
  33. stackport-0.3.1/ui/dist/assets/index-DLd-xBZq.css +1 -0
  34. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/index.html +2 -2
  35. stackport-0.2.6/backend/aws_client.py +0 -42
  36. stackport-0.2.6/backend/routes/common.py +0 -26
  37. stackport-0.2.6/backend/routes/endpoints.py +0 -39
  38. stackport-0.2.6/backend/routes/secretsmanager.py +0 -113
  39. stackport-0.2.6/tests/test_client.py +0 -17
  40. stackport-0.2.6/tests/test_endpoints.py +0 -116
  41. stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +0 -1
  42. stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +0 -614
  43. {stackport-0.2.6 → stackport-0.3.1}/LICENSE +0 -0
  44. {stackport-0.2.6 → stackport-0.3.1}/MANIFEST.in +0 -0
  45. {stackport-0.2.6 → stackport-0.3.1}/backend/__init__.py +0 -0
  46. {stackport-0.2.6 → stackport-0.3.1}/backend/cli.py +0 -0
  47. {stackport-0.2.6 → stackport-0.3.1}/backend/routes/__init__.py +0 -0
  48. {stackport-0.2.6 → stackport-0.3.1}/backend/schemas/__init__.py +0 -0
  49. {stackport-0.2.6 → stackport-0.3.1}/backend/schemas/dynamodb.py +0 -0
  50. {stackport-0.2.6 → stackport-0.3.1}/backend/schemas/s3.py +0 -0
  51. {stackport-0.2.6 → stackport-0.3.1}/backend/schemas/sqs.py +0 -0
  52. {stackport-0.2.6 → stackport-0.3.1}/backend/schemas/tags.py +0 -0
  53. {stackport-0.2.6 → stackport-0.3.1}/setup.cfg +0 -0
  54. {stackport-0.2.6 → stackport-0.3.1}/stackport.egg-info/dependency_links.txt +0 -0
  55. {stackport-0.2.6 → stackport-0.3.1}/stackport.egg-info/entry_points.txt +0 -0
  56. {stackport-0.2.6 → stackport-0.3.1}/stackport.egg-info/requires.txt +0 -0
  57. {stackport-0.2.6 → stackport-0.3.1}/stackport.egg-info/top_level.txt +0 -0
  58. {stackport-0.2.6 → stackport-0.3.1}/tests/test_cache.py +0 -0
  59. {stackport-0.2.6 → stackport-0.3.1}/tests/test_cli.py +0 -0
  60. {stackport-0.2.6 → stackport-0.3.1}/tests/test_config.py +0 -0
  61. {stackport-0.2.6 → stackport-0.3.1}/tests/test_dynamodb_routes.py +0 -0
  62. {stackport-0.2.6 → stackport-0.3.1}/tests/test_ec2_routes.py +0 -0
  63. {stackport-0.2.6 → stackport-0.3.1}/tests/test_iam_routes.py +0 -0
  64. {stackport-0.2.6 → stackport-0.3.1}/tests/test_lambda_routes.py +0 -0
  65. {stackport-0.2.6 → stackport-0.3.1}/tests/test_logs_routes.py +0 -0
  66. {stackport-0.2.6 → stackport-0.3.1}/tests/test_readonly_middleware.py +0 -0
  67. {stackport-0.2.6 → stackport-0.3.1}/tests/test_registries.py +0 -0
  68. {stackport-0.2.6 → stackport-0.3.1}/tests/test_routes.py +0 -0
  69. {stackport-0.2.6 → stackport-0.3.1}/tests/test_s3_routes.py +0 -0
  70. {stackport-0.2.6 → stackport-0.3.1}/tests/test_s3_upload_limit_env.py +0 -0
  71. {stackport-0.2.6 → stackport-0.3.1}/tests/test_sqs_routes.py +0 -0
  72. {stackport-0.2.6 → stackport-0.3.1}/tests/test_tags_routes.py +0 -0
  73. {stackport-0.2.6 → stackport-0.3.1}/tests/test_websocket.py +0 -0
  74. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/acm.svg +0 -0
  75. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/apigateway.svg +0 -0
  76. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/appsync.svg +0 -0
  77. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/athena.svg +0 -0
  78. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
  79. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
  80. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  81. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
  82. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/ec2.svg +0 -0
  83. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/ecr.svg +0 -0
  84. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/ecs.svg +0 -0
  85. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/elasticache.svg +0 -0
  86. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  87. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  88. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  89. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/events.svg +0 -0
  90. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/firehose.svg +0 -0
  91. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/glue.svg +0 -0
  92. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/iam.svg +0 -0
  93. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/kinesis.svg +0 -0
  94. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/kms.svg +0 -0
  95. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/lambda.svg +0 -0
  96. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/logs.svg +0 -0
  97. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/monitoring.svg +0 -0
  98. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/rds.svg +0 -0
  99. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/route53.svg +0 -0
  100. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/s3.svg +0 -0
  101. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  102. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/ses.svg +0 -0
  103. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/sns.svg +0 -0
  104. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/sqs.svg +0 -0
  105. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/ssm.svg +0 -0
  106. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  107. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/aws-icons/wafv2.svg +0 -0
  108. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/favicon.png +0 -0
  109. {stackport-0.2.6 → stackport-0.3.1}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.2.6
3
+ Version: 0.3.1
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -68,6 +68,7 @@ Dynamic: license-file
68
68
  - **8 dedicated service UIs** for S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager
69
69
  - **Write operations** — upload/delete S3 objects, query DynamoDB, invoke Lambda, send/receive SQS messages
70
70
  - **Real AWS support** — connect to real AWS accounts with read-only mode by default
71
+ - **Per-endpoint authentication** — default credentials, AWS profiles (SSO/AssumeRole), or static keys per endpoint
71
72
  - **Tag management** — unified tagging across 21 resource types
72
73
  - **CLI** — `stackport status`, `list`, `describe`, `export` with JSON/CSV/table output
73
74
  - **Real-time dashboard** with WebSocket-powered live updates
@@ -101,6 +102,8 @@ AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-west-2 stackpo
101
102
  STACKPORT_ALLOW_WRITES=false AWS_PROFILE=my-profile stackport
102
103
  ```
103
104
 
105
+ You can also configure per-endpoint authentication from the Settings UI — select a profile, enter static credentials, or use the default credential chain per endpoint. See [Per-endpoint authentication](#per-endpoint-authentication) below.
106
+
104
107
  When connected to real AWS, StackPort shows a warning banner and operates in read-only mode unless writes are explicitly enabled.
105
108
 
106
109
  ### Docker Compose (MiniStack + StackPort)
@@ -159,6 +162,30 @@ See [`examples/docker-compose.multi-endpoint.yml`](examples/docker-compose.multi
159
162
 
160
163
  The endpoint selector appears in the sidebar when more than one endpoint is configured. Each endpoint is health-checked independently, and all API requests, caches, and WebSocket subscriptions are scoped to the active endpoint.
161
164
 
165
+ ### Per-endpoint authentication
166
+
167
+ Each endpoint can use its own AWS authentication method, configured from the Settings UI:
168
+
169
+ | Auth Type | Description | Use Case |
170
+ |-----------|-------------|----------|
171
+ | **Default** | Uses `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars or instance role | Local emulators, EC2/ECS deployments |
172
+ | **AWS Profile** | Uses a named profile from `~/.aws/config` | SSO, AssumeRole, multiple accounts |
173
+ | **Static Credentials** | Per-endpoint access key and secret | Service accounts, cross-account access |
174
+
175
+ **Profile auth** supports SSO, AssumeRole, and credential_process — anything `boto3.Session(profile_name=...)` handles. If your SSO token expires, StackPort returns a 401 with instructions to run `aws sso login --profile <name>`.
176
+
177
+ **Docker users:** mount your AWS config to use profiles inside the container:
178
+
179
+ ```yaml
180
+ services:
181
+ stackport:
182
+ image: davireis/stackport
183
+ volumes:
184
+ - ~/.aws:/root/.aws
185
+ ```
186
+
187
+ You can test connections before saving using the "Test Connection" button in the add/edit endpoint dialog.
188
+
162
189
  ## Service Browsers
163
190
 
164
191
  ### Dedicated UIs (8 services)
@@ -240,6 +267,7 @@ Press `?` anywhere to see all shortcuts.
240
267
  | `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
241
268
  | `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
242
269
  | `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
270
+ | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
243
271
  | `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
244
272
 
245
273
  ## Supported Services (35)
@@ -263,8 +291,8 @@ cd ui && npm install && npm run dev
263
291
  cd ui && npm run build
264
292
 
265
293
  # Run tests
266
- python -m pytest tests/ -x --tb=short # backend (262 tests)
267
- cd ui && npx vitest run # frontend (163 tests)
294
+ python -m pytest tests/ -x --tb=short # backend (362 tests)
295
+ cd ui && npx vitest run # frontend (176 tests)
268
296
 
269
297
  # Typecheck & lint
270
298
  cd ui && npx tsc -b
@@ -285,6 +313,7 @@ backend/
285
313
  routes/
286
314
  stats.py Service discovery with concurrent probing
287
315
  resources.py Generic list/detail for all services
316
+ endpoints.py Endpoint CRUD, health checks, profile listing
288
317
  tags.py Unified tag management (21 types)
289
318
  s3.py S3 file browser with write ops
290
319
  dynamodb.py DynamoDB query/scan
@@ -33,6 +33,7 @@
33
33
  - **8 dedicated service UIs** for S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager
34
34
  - **Write operations** — upload/delete S3 objects, query DynamoDB, invoke Lambda, send/receive SQS messages
35
35
  - **Real AWS support** — connect to real AWS accounts with read-only mode by default
36
+ - **Per-endpoint authentication** — default credentials, AWS profiles (SSO/AssumeRole), or static keys per endpoint
36
37
  - **Tag management** — unified tagging across 21 resource types
37
38
  - **CLI** — `stackport status`, `list`, `describe`, `export` with JSON/CSV/table output
38
39
  - **Real-time dashboard** with WebSocket-powered live updates
@@ -66,6 +67,8 @@ AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-west-2 stackpo
66
67
  STACKPORT_ALLOW_WRITES=false AWS_PROFILE=my-profile stackport
67
68
  ```
68
69
 
70
+ You can also configure per-endpoint authentication from the Settings UI — select a profile, enter static credentials, or use the default credential chain per endpoint. See [Per-endpoint authentication](#per-endpoint-authentication) below.
71
+
69
72
  When connected to real AWS, StackPort shows a warning banner and operates in read-only mode unless writes are explicitly enabled.
70
73
 
71
74
  ### Docker Compose (MiniStack + StackPort)
@@ -124,6 +127,30 @@ See [`examples/docker-compose.multi-endpoint.yml`](examples/docker-compose.multi
124
127
 
125
128
  The endpoint selector appears in the sidebar when more than one endpoint is configured. Each endpoint is health-checked independently, and all API requests, caches, and WebSocket subscriptions are scoped to the active endpoint.
126
129
 
130
+ ### Per-endpoint authentication
131
+
132
+ Each endpoint can use its own AWS authentication method, configured from the Settings UI:
133
+
134
+ | Auth Type | Description | Use Case |
135
+ |-----------|-------------|----------|
136
+ | **Default** | Uses `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars or instance role | Local emulators, EC2/ECS deployments |
137
+ | **AWS Profile** | Uses a named profile from `~/.aws/config` | SSO, AssumeRole, multiple accounts |
138
+ | **Static Credentials** | Per-endpoint access key and secret | Service accounts, cross-account access |
139
+
140
+ **Profile auth** supports SSO, AssumeRole, and credential_process — anything `boto3.Session(profile_name=...)` handles. If your SSO token expires, StackPort returns a 401 with instructions to run `aws sso login --profile <name>`.
141
+
142
+ **Docker users:** mount your AWS config to use profiles inside the container:
143
+
144
+ ```yaml
145
+ services:
146
+ stackport:
147
+ image: davireis/stackport
148
+ volumes:
149
+ - ~/.aws:/root/.aws
150
+ ```
151
+
152
+ You can test connections before saving using the "Test Connection" button in the add/edit endpoint dialog.
153
+
127
154
  ## Service Browsers
128
155
 
129
156
  ### Dedicated UIs (8 services)
@@ -205,6 +232,7 @@ Press `?` anywhere to see all shortcuts.
205
232
  | `STACKPORT_CACHE_TTL` | `5` | Seconds to cache service stats |
206
233
  | `STACKPORT_PROBE_WORKERS` | `10` | Max concurrent workers for service probing |
207
234
  | `STACKPORT_ENDPOINTS` | *(unset)* | Multiple endpoints: `local=http://localhost:4566,staging=http://...` |
235
+ | `STACKPORT_DATA_DIR` | `~/.stackport` | Directory for persistent config (endpoints.json) |
208
236
  | `LOG_LEVEL` | `INFO` | Python log level (`DEBUG` shows healthcheck logs) |
209
237
 
210
238
  ## Supported Services (35)
@@ -228,8 +256,8 @@ cd ui && npm install && npm run dev
228
256
  cd ui && npm run build
229
257
 
230
258
  # Run tests
231
- python -m pytest tests/ -x --tb=short # backend (262 tests)
232
- cd ui && npx vitest run # frontend (163 tests)
259
+ python -m pytest tests/ -x --tb=short # backend (362 tests)
260
+ cd ui && npx vitest run # frontend (176 tests)
233
261
 
234
262
  # Typecheck & lint
235
263
  cd ui && npx tsc -b
@@ -250,6 +278,7 @@ backend/
250
278
  routes/
251
279
  stats.py Service discovery with concurrent probing
252
280
  resources.py Generic list/detail for all services
281
+ endpoints.py Endpoint CRUD, health checks, profile listing
253
282
  tags.py Unified tag management (21 types)
254
283
  s3.py S3 file browser with write ops
255
284
  dynamodb.py DynamoDB query/scan
@@ -0,0 +1,63 @@
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
+ endpoint_store,
10
+ )
11
+
12
+ _UNSET = object()
13
+
14
+
15
+ @functools.lru_cache(maxsize=256)
16
+ def get_client(
17
+ service_name: str,
18
+ endpoint_url: str | None = _UNSET,
19
+ region: str | None = None,
20
+ auth_type: str = "default",
21
+ auth_profile: str | None = None,
22
+ auth_access_key_id: str | None = None,
23
+ auth_secret_access_key: str | None = None,
24
+ ):
25
+ """Return a boto3 client for the given service and endpoint.
26
+
27
+ Args:
28
+ service_name: AWS service name (e.g., "s3", "dynamodb")
29
+ endpoint_url: Endpoint URL. None means real AWS (no custom endpoint).
30
+ Omitted (sentinel) means use default endpoint.
31
+ region: Per-endpoint region override. None means use global AWS_REGION.
32
+ auth_type: "default", "profile", or "credentials"
33
+ auth_profile: AWS profile name (for auth_type="profile")
34
+ auth_access_key_id: Access key (for auth_type="credentials")
35
+ auth_secret_access_key: Secret key (for auth_type="credentials")
36
+
37
+ Returns:
38
+ Configured boto3 client
39
+ """
40
+ url = endpoint_store.get_default_url() if endpoint_url is _UNSET else endpoint_url
41
+ resolved_region = region or AWS_REGION
42
+
43
+ if auth_type == "profile" and auth_profile:
44
+ session = boto3.Session(profile_name=auth_profile, region_name=resolved_region)
45
+ elif auth_type == "credentials" and auth_access_key_id and auth_secret_access_key:
46
+ session = boto3.Session(
47
+ aws_access_key_id=auth_access_key_id,
48
+ aws_secret_access_key=auth_secret_access_key,
49
+ region_name=resolved_region,
50
+ )
51
+ else:
52
+ session_kwargs: dict = {"region_name": resolved_region}
53
+ if AWS_ACCESS_KEY_ID is not None:
54
+ session_kwargs["aws_access_key_id"] = AWS_ACCESS_KEY_ID
55
+ if AWS_SECRET_ACCESS_KEY is not None:
56
+ session_kwargs["aws_secret_access_key"] = AWS_SECRET_ACCESS_KEY
57
+ session = boto3.Session(**session_kwargs)
58
+
59
+ client_kwargs: dict = {"service_name": service_name}
60
+ if url is not None:
61
+ client_kwargs["endpoint_url"] = url
62
+
63
+ return session.client(**client_kwargs)
@@ -29,5 +29,17 @@ class TTLCache:
29
29
  with self._lock:
30
30
  self._store.pop(key, None)
31
31
 
32
+ def delete_by_prefix(self, prefix: str) -> int:
33
+ """Delete all keys starting with the given prefix.
34
+
35
+ Returns:
36
+ Number of keys deleted
37
+ """
38
+ with self._lock:
39
+ to_delete = [k for k in self._store if k.startswith(prefix)]
40
+ for k in to_delete:
41
+ del self._store[k]
42
+ return len(to_delete)
43
+
32
44
 
33
45
  cache = TTLCache()
@@ -1,10 +1,16 @@
1
1
  import logging
2
2
  import os
3
3
  from collections.abc import Mapping
4
+ from pathlib import Path
4
5
 
5
6
  logger = logging.getLogger(__name__)
6
7
 
7
8
  AWS_ENDPOINT_URL: str | None = os.environ.get("AWS_ENDPOINT_URL") # None = real AWS
9
+
10
+ # Remove AWS_ENDPOINT_URL from the process environment so boto3's internal
11
+ # credential resolution (SSO, AssumeRole) doesn't route to a local emulator.
12
+ if "AWS_ENDPOINT_URL" in os.environ:
13
+ del os.environ["AWS_ENDPOINT_URL"]
8
14
  AWS_REGION: str = os.environ.get("AWS_REGION", "us-east-1")
9
15
  AWS_ACCESS_KEY_ID: str | None = os.environ.get("AWS_ACCESS_KEY_ID")
10
16
  AWS_SECRET_ACCESS_KEY: str | None = os.environ.get("AWS_SECRET_ACCESS_KEY")
@@ -85,6 +91,17 @@ def _parse_endpoints() -> dict[str, str | None]:
85
91
  ENDPOINTS: dict[str, str | None] = _parse_endpoints()
86
92
  DEFAULT_ENDPOINT: str | None = next(iter(ENDPOINTS.values()))
87
93
 
94
+ # Data directory for persistent config (endpoints.json, etc.)
95
+ STACKPORT_DATA_DIR: Path = Path(os.environ.get("STACKPORT_DATA_DIR", Path.home() / ".stackport"))
96
+
97
+ # Initialize endpoint store
98
+ from backend.endpoint_store import EndpointStore
99
+
100
+ endpoint_store = EndpointStore(
101
+ json_path=STACKPORT_DATA_DIR / "endpoints.json",
102
+ env_endpoints=ENDPOINTS,
103
+ )
104
+
88
105
 
89
106
  _UNSET = object()
90
107
 
@@ -0,0 +1,409 @@
1
+ """Persistent endpoint configuration with runtime CRUD.
2
+
3
+ Endpoints are stored in a JSON file with atomic writes. The store is thread-safe
4
+ and supports env-var seeding on first run.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import threading
11
+ from pathlib import Path
12
+ from typing import TypedDict
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _UNSET = object()
17
+
18
+
19
+ class EndpointEntry(TypedDict):
20
+ """Single endpoint configuration."""
21
+
22
+ url: str | None # None means real AWS
23
+ source: str # "env" or "user"
24
+ region: str | None # Optional per-endpoint region override
25
+ auth_type: str # "default", "profile", or "credentials"
26
+ auth_profile: str | None # AWS profile name (when auth_type="profile")
27
+ auth_access_key_id: str | None # (when auth_type="credentials")
28
+ auth_secret_access_key: str | None # (when auth_type="credentials")
29
+
30
+
31
+ class EndpointsConfig(TypedDict):
32
+ """JSON file format."""
33
+
34
+ version: int
35
+ default: str
36
+ deleted_env_names: list[str]
37
+ endpoints: dict[str, EndpointEntry]
38
+
39
+
40
+ class EndpointStore:
41
+ """Thread-safe, persistent endpoint configuration store."""
42
+
43
+ def __init__(self, json_path: Path, env_endpoints: dict[str, str | None]):
44
+ """Initialize the store.
45
+
46
+ Args:
47
+ json_path: Path to the persistent JSON file
48
+ env_endpoints: Initial endpoints from STACKPORT_ENDPOINTS env var
49
+ """
50
+ self.json_path = json_path
51
+ self.env_endpoints = env_endpoints
52
+ self._lock = threading.RLock()
53
+ self._config: EndpointsConfig | None = None
54
+ self._load_or_initialize()
55
+
56
+ def _load_or_initialize(self) -> None:
57
+ """Load existing JSON or initialize from env vars."""
58
+ with self._lock:
59
+ if self.json_path.exists():
60
+ try:
61
+ with open(self.json_path, "r", encoding="utf-8") as f:
62
+ self._config = json.load(f)
63
+ logger.info("Loaded endpoints from %s", self.json_path)
64
+ # Seed any new env endpoints not already present or deleted
65
+ self._seed_from_env()
66
+ except (json.JSONDecodeError, OSError) as e:
67
+ logger.warning("Failed to load %s: %s. Initializing from env.", self.json_path, e)
68
+ self._initialize_from_env()
69
+ else:
70
+ self._initialize_from_env()
71
+
72
+ def _initialize_from_env(self) -> None:
73
+ """Create initial config from env vars."""
74
+ endpoints: dict[str, EndpointEntry] = {}
75
+ for name, url in self.env_endpoints.items():
76
+ endpoints[name] = {
77
+ "url": url,
78
+ "source": "env",
79
+ "region": None,
80
+ "auth_type": "default",
81
+ "auth_profile": None,
82
+ "auth_access_key_id": None,
83
+ "auth_secret_access_key": None,
84
+ }
85
+
86
+ default_name = next(iter(self.env_endpoints.keys())) if self.env_endpoints else "default"
87
+ self._config = {
88
+ "version": 2,
89
+ "default": default_name,
90
+ "deleted_env_names": [],
91
+ "endpoints": endpoints,
92
+ }
93
+ self._save()
94
+ logger.info("Initialized endpoints from env: %s", list(self.env_endpoints.keys()))
95
+
96
+ def _seed_from_env(self) -> None:
97
+ """Add env endpoints that aren't present and weren't deleted."""
98
+ if not self._config:
99
+ return
100
+ self._migrate_v1_to_v2()
101
+ deleted = set(self._config["deleted_env_names"])
102
+ added = []
103
+ for name, url in self.env_endpoints.items():
104
+ if name not in self._config["endpoints"] and name not in deleted:
105
+ self._config["endpoints"][name] = {
106
+ "url": url,
107
+ "source": "env",
108
+ "region": None,
109
+ "auth_type": "default",
110
+ "auth_profile": None,
111
+ "auth_access_key_id": None,
112
+ "auth_secret_access_key": None,
113
+ }
114
+ added.append(name)
115
+ if added:
116
+ self._save()
117
+ logger.info("Seeded new env endpoints: %s", added)
118
+
119
+ def _migrate_v1_to_v2(self) -> None:
120
+ """Migrate v1 config (no auth fields) to v2."""
121
+ if not self._config or self._config.get("version", 1) >= 2:
122
+ return
123
+ for entry in self._config["endpoints"].values():
124
+ entry.setdefault("auth_type", "default")
125
+ entry.setdefault("auth_profile", None)
126
+ entry.setdefault("auth_access_key_id", None)
127
+ entry.setdefault("auth_secret_access_key", None)
128
+ self._config["version"] = 2
129
+ self._save()
130
+ logger.info("Migrated endpoints config from v1 to v2 (added auth fields)")
131
+
132
+ def _save(self) -> None:
133
+ """Atomically write config to disk."""
134
+ if not self._config:
135
+ return
136
+ tmp_path = self.json_path.with_suffix(".tmp")
137
+ try:
138
+ self.json_path.parent.mkdir(parents=True, exist_ok=True)
139
+ with open(tmp_path, "w", encoding="utf-8") as f:
140
+ json.dump(self._config, f, indent=2, ensure_ascii=False)
141
+ f.flush()
142
+ os.fsync(f.fileno())
143
+ os.replace(tmp_path, self.json_path)
144
+ except OSError as e:
145
+ logger.error("Failed to write %s: %s", self.json_path, e)
146
+ if tmp_path.exists():
147
+ try:
148
+ tmp_path.unlink()
149
+ except OSError:
150
+ pass
151
+ raise
152
+
153
+ def list_all(self) -> dict[str, EndpointEntry]:
154
+ """Return all endpoints."""
155
+ with self._lock:
156
+ if not self._config:
157
+ return {}
158
+ return dict(self._config["endpoints"])
159
+
160
+ def get_default_name(self) -> str:
161
+ """Return the default endpoint name."""
162
+ with self._lock:
163
+ if not self._config or not self._config["endpoints"]:
164
+ return "default"
165
+ return self._config["default"]
166
+
167
+ def get_default_url(self) -> str | None:
168
+ """Return the default endpoint URL."""
169
+ with self._lock:
170
+ if not self._config or not self._config["endpoints"]:
171
+ return None
172
+ default_name = self._config["default"]
173
+ entry = self._config["endpoints"].get(default_name)
174
+ return entry["url"] if entry else None
175
+
176
+ def get(self, name: str) -> EndpointEntry | None:
177
+ """Get a single endpoint by name."""
178
+ with self._lock:
179
+ if not self._config:
180
+ return None
181
+ return self._config["endpoints"].get(name)
182
+
183
+ def add(
184
+ self,
185
+ name: str,
186
+ url: str | None,
187
+ region: str | None = None,
188
+ auth_type: str = "default",
189
+ auth_profile: str | None = None,
190
+ auth_access_key_id: str | None = None,
191
+ auth_secret_access_key: str | None = None,
192
+ ) -> None:
193
+ """Add a new endpoint.
194
+
195
+ Raises:
196
+ ValueError: If name already exists or is invalid
197
+ """
198
+ with self._lock:
199
+ if not self._config:
200
+ raise RuntimeError("Config not initialized")
201
+ if name in self._config["endpoints"]:
202
+ raise ValueError(f"Endpoint '{name}' already exists")
203
+ if not name or not name.replace("-", "").replace("_", "").isalnum():
204
+ raise ValueError(f"Invalid endpoint name '{name}'")
205
+ if len(name) > 50:
206
+ raise ValueError(f"Endpoint name too long (max 50): '{name}'")
207
+
208
+ self._config["endpoints"][name] = {
209
+ "url": url,
210
+ "source": "user",
211
+ "region": region,
212
+ "auth_type": auth_type,
213
+ "auth_profile": auth_profile,
214
+ "auth_access_key_id": auth_access_key_id,
215
+ "auth_secret_access_key": auth_secret_access_key,
216
+ }
217
+ # If this is the first endpoint, make it default
218
+ if len(self._config["endpoints"]) == 1:
219
+ self._config["default"] = name
220
+ self._save()
221
+ logger.info("Added endpoint: %s → %s", name, url)
222
+
223
+ def update(
224
+ self,
225
+ name: str,
226
+ url: str | None | object = _UNSET,
227
+ region: str | None | object = _UNSET,
228
+ auth_type: str | object = _UNSET,
229
+ auth_profile: str | None | object = _UNSET,
230
+ auth_access_key_id: str | None | object = _UNSET,
231
+ auth_secret_access_key: str | None | object = _UNSET,
232
+ ) -> None:
233
+ """Update an existing endpoint.
234
+
235
+ Args:
236
+ name: Endpoint name
237
+ url: New URL (None to use real AWS, unset to keep current)
238
+ region: New region (None to clear, unset to keep current)
239
+ auth_type: Auth type ("default", "profile", "credentials")
240
+ auth_profile: AWS profile name
241
+ auth_access_key_id: Access key ID for credentials auth
242
+ auth_secret_access_key: Secret access key for credentials auth
243
+
244
+ Raises:
245
+ ValueError: If endpoint doesn't exist
246
+ """
247
+ with self._lock:
248
+ if not self._config:
249
+ raise RuntimeError("Config not initialized")
250
+ if name not in self._config["endpoints"]:
251
+ raise ValueError(f"Endpoint '{name}' not found")
252
+
253
+ entry = self._config["endpoints"][name]
254
+ if url is not _UNSET:
255
+ entry["url"] = url # type: ignore
256
+ if region is not _UNSET:
257
+ entry["region"] = region # type: ignore
258
+ if auth_type is not _UNSET:
259
+ entry["auth_type"] = auth_type # type: ignore
260
+ if auth_profile is not _UNSET:
261
+ entry["auth_profile"] = auth_profile # type: ignore
262
+ if auth_access_key_id is not _UNSET:
263
+ entry["auth_access_key_id"] = auth_access_key_id # type: ignore
264
+ if auth_secret_access_key is not _UNSET:
265
+ entry["auth_secret_access_key"] = auth_secret_access_key # type: ignore
266
+
267
+ self._save()
268
+ logger.info("Updated endpoint: %s", name)
269
+
270
+ def remove(self, name: str) -> None:
271
+ """Remove an endpoint.
272
+
273
+ Raises:
274
+ ValueError: If endpoint doesn't exist or is the last one
275
+ """
276
+ with self._lock:
277
+ if not self._config:
278
+ raise RuntimeError("Config not initialized")
279
+ if name not in self._config["endpoints"]:
280
+ raise ValueError(f"Endpoint '{name}' not found")
281
+ if len(self._config["endpoints"]) == 1:
282
+ raise ValueError("Cannot delete the last endpoint")
283
+
284
+ entry = self._config["endpoints"].pop(name)
285
+ # Track deleted env endpoints to prevent re-seeding
286
+ if entry["source"] == "env":
287
+ if name not in self._config["deleted_env_names"]:
288
+ self._config["deleted_env_names"].append(name)
289
+
290
+ # If we deleted the default, pick a new one
291
+ if self._config["default"] == name:
292
+ self._config["default"] = next(iter(self._config["endpoints"].keys()))
293
+
294
+ self._save()
295
+ logger.info("Removed endpoint: %s", name)
296
+
297
+ def set_default(self, name: str) -> None:
298
+ """Set the default endpoint.
299
+
300
+ Raises:
301
+ ValueError: If endpoint doesn't exist
302
+ """
303
+ with self._lock:
304
+ if not self._config:
305
+ raise RuntimeError("Config not initialized")
306
+ if name not in self._config["endpoints"]:
307
+ raise ValueError(f"Endpoint '{name}' not found")
308
+
309
+ self._config["default"] = name
310
+ self._save()
311
+ logger.info("Set default endpoint: %s", name)
312
+
313
+ def resolve(self, endpoint_name_or_url: str | None) -> str | None:
314
+ """Resolve an endpoint name or URL to a URL.
315
+
316
+ Args:
317
+ endpoint_name_or_url: Endpoint name, direct URL, or None
318
+
319
+ Returns:
320
+ - If None: returns default URL
321
+ - If starts with http:// or https://: returns as-is (direct URL)
322
+ - Otherwise: looks up name and returns URL, or default if not found
323
+ """
324
+ with self._lock:
325
+ if not self._config:
326
+ return None
327
+
328
+ # None → default
329
+ if endpoint_name_or_url is None:
330
+ return self.get_default_url()
331
+
332
+ # Direct URL passthrough
333
+ if endpoint_name_or_url.startswith("http://") or endpoint_name_or_url.startswith("https://"):
334
+ return endpoint_name_or_url
335
+
336
+ # Look up by name
337
+ entry = self._config["endpoints"].get(endpoint_name_or_url)
338
+ if entry:
339
+ return entry["url"]
340
+
341
+ # Fallback to default
342
+ return self.get_default_url()
343
+
344
+ def resolve_with_region(self, endpoint_name_or_url: str | None) -> tuple[str | None, str | None]:
345
+ """Resolve an endpoint to both URL and region.
346
+
347
+ Unlike resolve(), this preserves the endpoint name → region mapping,
348
+ avoiding ambiguity when multiple endpoints share the same URL.
349
+
350
+ Returns:
351
+ Tuple of (url, region). Region is None if not set.
352
+ """
353
+ with self._lock:
354
+ if not self._config:
355
+ return None, None
356
+
357
+ if endpoint_name_or_url is None:
358
+ default_name = self._config["default"]
359
+ entry = self._config["endpoints"].get(default_name)
360
+ if entry:
361
+ return entry["url"], entry.get("region")
362
+ return None, None
363
+
364
+ if endpoint_name_or_url.startswith("http://") or endpoint_name_or_url.startswith("https://"):
365
+ return endpoint_name_or_url, None
366
+
367
+ entry = self._config["endpoints"].get(endpoint_name_or_url)
368
+ if entry:
369
+ return entry["url"], entry.get("region")
370
+
371
+ default_name = self._config["default"]
372
+ default_entry = self._config["endpoints"].get(default_name)
373
+ if default_entry:
374
+ return default_entry["url"], default_entry.get("region")
375
+ return None, None
376
+
377
+ def resolve_full(self, endpoint_name_or_url: str | None) -> EndpointEntry | None:
378
+ """Resolve an endpoint to the full entry including auth info.
379
+
380
+ Returns:
381
+ Full EndpointEntry or None if not found.
382
+ """
383
+ with self._lock:
384
+ if not self._config:
385
+ return None
386
+
387
+ if endpoint_name_or_url is None:
388
+ default_name = self._config["default"]
389
+ return self._config["endpoints"].get(default_name)
390
+
391
+ if endpoint_name_or_url.startswith("http://") or endpoint_name_or_url.startswith("https://"):
392
+ return None
393
+
394
+ entry = self._config["endpoints"].get(endpoint_name_or_url)
395
+ if entry:
396
+ return entry
397
+
398
+ default_name = self._config["default"]
399
+ return self._config["endpoints"].get(default_name)
400
+
401
+ def get_region_for_url(self, url: str | None) -> str | None:
402
+ """Look up the per-endpoint region for a given URL."""
403
+ with self._lock:
404
+ if not self._config:
405
+ return None
406
+ for entry in self._config["endpoints"].values():
407
+ if entry["url"] == url:
408
+ return entry.get("region")
409
+ return None