stackport 0.2.6__tar.gz → 0.3.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.2.6/stackport.egg-info → stackport-0.3.0}/PKG-INFO +32 -3
- {stackport-0.2.6 → stackport-0.3.0}/README.md +31 -2
- stackport-0.3.0/backend/aws_client.py +63 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/cache.py +12 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/config.py +17 -0
- stackport-0.3.0/backend/endpoint_store.py +409 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/main.py +24 -1
- stackport-0.3.0/backend/routes/common.py +63 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/dynamodb.py +18 -18
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/ec2.py +19 -19
- stackport-0.3.0/backend/routes/endpoints.py +297 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/iam.py +17 -17
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/lambda_svc.py +15 -15
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/logs.py +9 -9
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/resources.py +10 -10
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/s3.py +44 -44
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/secretsmanager.py +5 -5
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/sqs.py +25 -25
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/stats.py +16 -10
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/tags.py +10 -10
- stackport-0.3.0/backend/schemas/endpoints.py +38 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/websocket.py +42 -8
- {stackport-0.2.6 → stackport-0.3.0}/pyproject.toml +1 -1
- {stackport-0.2.6 → stackport-0.3.0/stackport.egg-info}/PKG-INFO +32 -3
- {stackport-0.2.6 → stackport-0.3.0}/stackport.egg-info/SOURCES.txt +7 -4
- stackport-0.3.0/tests/test_client.py +34 -0
- stackport-0.3.0/tests/test_endpoint_store.py +516 -0
- stackport-0.3.0/tests/test_endpoints.py +460 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_multi_endpoint.py +26 -24
- stackport-0.3.0/ui/dist/assets/index-Cda_0qu5.css +1 -0
- stackport-0.3.0/ui/dist/assets/index-tW-8e4eS.js +629 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/index.html +2 -2
- stackport-0.2.6/backend/aws_client.py +0 -42
- stackport-0.2.6/backend/routes/common.py +0 -26
- stackport-0.2.6/backend/routes/endpoints.py +0 -39
- stackport-0.2.6/tests/test_client.py +0 -17
- stackport-0.2.6/tests/test_endpoints.py +0 -116
- stackport-0.2.6/ui/dist/assets/index-C-DyQfZs.css +0 -1
- stackport-0.2.6/ui/dist/assets/index-DYRN3E1e.js +0 -614
- {stackport-0.2.6 → stackport-0.3.0}/LICENSE +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/MANIFEST.in +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/__init__.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/cli.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/routes/__init__.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/schemas/__init__.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/schemas/s3.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/schemas/sqs.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/backend/schemas/tags.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/setup.cfg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_cache.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_cli.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_config.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_ec2_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_iam_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_lambda_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_logs_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_registries.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_s3_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_sqs_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_tags_routes.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/tests/test_websocket.py +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.2.6 → stackport-0.3.0}/ui/dist/favicon.png +0 -0
- {stackport-0.2.6 → stackport-0.3.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.3.0
|
|
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 (
|
|
267
|
-
cd ui && npx vitest run # frontend (
|
|
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 (
|
|
232
|
-
cd ui && npx vitest run # frontend (
|
|
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
|