stackport 0.2.1__tar.gz → 0.2.2__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.1/stackport.egg-info → stackport-0.2.2}/PKG-INFO +1 -1
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/dynamodb.py +1 -7
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/s3.py +1 -28
- stackport-0.2.2/backend/routes/sqs.py +575 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/tags.py +1 -15
- stackport-0.2.2/backend/schemas/__init__.py +11 -0
- stackport-0.2.2/backend/schemas/dynamodb.py +10 -0
- stackport-0.2.2/backend/schemas/s3.py +30 -0
- stackport-0.2.2/backend/schemas/sqs.py +80 -0
- stackport-0.2.2/backend/schemas/tags.py +17 -0
- {stackport-0.2.1 → stackport-0.2.2}/pyproject.toml +1 -1
- {stackport-0.2.1 → stackport-0.2.2/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/SOURCES.txt +9 -4
- stackport-0.2.2/tests/test_sqs_routes.py +776 -0
- stackport-0.2.2/ui/dist/assets/index-B2xjVeE-.js +609 -0
- stackport-0.2.2/ui/dist/assets/index-D_vb_J84.css +1 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/index.html +2 -2
- stackport-0.2.1/backend/routes/sqs.py +0 -308
- stackport-0.2.1/tests/test_sqs_routes.py +0 -290
- stackport-0.2.1/ui/dist/assets/index-BNpVm9Z9.js +0 -568
- stackport-0.2.1/ui/dist/assets/index-CgkBNKzX.css +0 -1
- {stackport-0.2.1 → stackport-0.2.2}/LICENSE +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/MANIFEST.in +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/README.md +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/__init__.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/aws_client.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/cache.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/cli.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/config.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/main.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/__init__.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/common.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/ec2.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/endpoints.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/iam.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/logs.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/resources.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/routes/stats.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/backend/websocket.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/setup.cfg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_cache.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_cli.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_client.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_config.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_ec2_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_endpoints.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_iam_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_lambda_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_logs_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_registries.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_s3_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_tags_routes.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/tests/test_websocket.py +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/favicon.png +0 -0
- {stackport-0.2.1 → stackport-0.2.2}/ui/dist/favicon.svg +0 -0
|
@@ -2,11 +2,11 @@ import logging
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from fastapi import APIRouter, Depends, Query
|
|
5
|
-
from pydantic import BaseModel
|
|
6
5
|
|
|
7
6
|
from backend.aws_client import get_client
|
|
8
7
|
from backend.cache import cache
|
|
9
8
|
from backend.routes.common import get_endpoint_url
|
|
9
|
+
from backend.schemas.dynamodb import QueryRequest
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
@@ -154,12 +154,6 @@ def scan_table(
|
|
|
154
154
|
return {"error": str(e), "items": [], "count": 0}
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
class QueryRequest(BaseModel):
|
|
158
|
-
partition_key_value: str
|
|
159
|
-
sort_key_value: str | None = None
|
|
160
|
-
sort_key_operator: str = "=" # =, <, <=, >, >=, BETWEEN, BEGINS_WITH
|
|
161
|
-
limit: int = 25
|
|
162
|
-
|
|
163
157
|
|
|
164
158
|
@router.post("/tables/{name}/query")
|
|
165
159
|
def query_table(name: str, request: QueryRequest, endpoint_url: str | None = Depends(get_endpoint_url)):
|
|
@@ -6,12 +6,11 @@ from typing import Annotated
|
|
|
6
6
|
from botocore.exceptions import ClientError
|
|
7
7
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
8
8
|
from fastapi.responses import StreamingResponse
|
|
9
|
-
from pydantic import BaseModel, model_validator
|
|
10
|
-
|
|
11
9
|
from backend.aws_client import get_client
|
|
12
10
|
from backend.cache import cache
|
|
13
11
|
from backend.config import AWS_REGION, S3_MAX_UPLOAD_BYTES, is_local_endpoint
|
|
14
12
|
from backend.routes.common import get_endpoint_url
|
|
13
|
+
from backend.schemas.s3 import CreateFolderBody, DeleteBatchBody
|
|
15
14
|
|
|
16
15
|
logger = logging.getLogger(__name__)
|
|
17
16
|
|
|
@@ -191,32 +190,6 @@ def list_objects(
|
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
|
|
194
|
-
class DeleteBatchBody(BaseModel):
|
|
195
|
-
"""Delete by explicit keys or by prefix (recursive). Provide exactly one."""
|
|
196
|
-
|
|
197
|
-
keys: list[str] | None = None
|
|
198
|
-
prefix: str | None = None
|
|
199
|
-
|
|
200
|
-
@model_validator(mode="after")
|
|
201
|
-
def exactly_one_mode(self):
|
|
202
|
-
has_keys = bool(self.keys)
|
|
203
|
-
has_prefix = bool(self.prefix and self.prefix.strip())
|
|
204
|
-
if has_keys == has_prefix:
|
|
205
|
-
raise ValueError('Provide exactly one of non-empty "keys" or "prefix"')
|
|
206
|
-
return self
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
class CreateFolderBody(BaseModel):
|
|
210
|
-
prefix: str
|
|
211
|
-
|
|
212
|
-
@model_validator(mode="after")
|
|
213
|
-
def trailing_slash(self):
|
|
214
|
-
if not self.prefix.endswith("/"):
|
|
215
|
-
raise ValueError('Folder prefix must end with "/"')
|
|
216
|
-
if ".." in self.prefix or self.prefix.startswith("/"):
|
|
217
|
-
raise ValueError("Invalid prefix")
|
|
218
|
-
return self
|
|
219
|
-
|
|
220
193
|
|
|
221
194
|
def _validate_object_key(key: str) -> None:
|
|
222
195
|
if ".." in key or key.startswith("/"):
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""SQS service-specific routes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from fastapi.responses import Response
|
|
9
|
+
|
|
10
|
+
from backend.aws_client import get_client
|
|
11
|
+
from backend.routes.common import get_endpoint_url
|
|
12
|
+
from backend.schemas.sqs import (
|
|
13
|
+
BatchDeleteRequest,
|
|
14
|
+
BatchSendRequest,
|
|
15
|
+
CreateQueueRequest,
|
|
16
|
+
SendMessageRequest,
|
|
17
|
+
UpdateAttributesRequest,
|
|
18
|
+
UpdateRedrivePolicyRequest,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_queue_name(queue_url: str) -> str:
|
|
25
|
+
"""Extract queue name from SQS URL."""
|
|
26
|
+
return queue_url.rsplit("/", 1)[-1]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_redrive_policy(redrive_policy_json: str | None) -> dict[str, Any] | None:
|
|
30
|
+
"""Parse RedrivePolicy JSON string into structured dict."""
|
|
31
|
+
if not redrive_policy_json:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(redrive_policy_json)
|
|
35
|
+
except (json.JSONDecodeError, TypeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/queues")
|
|
40
|
+
def list_queues(endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
41
|
+
"""List all SQS queues with enriched attributes.
|
|
42
|
+
|
|
43
|
+
Returns queue name, URL, message counts, type, and key attributes.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
client = get_client("sqs", endpoint_url)
|
|
47
|
+
response = client.list_queues()
|
|
48
|
+
queue_urls = response.get("QueueUrls", [])
|
|
49
|
+
|
|
50
|
+
queues = []
|
|
51
|
+
for url in queue_urls:
|
|
52
|
+
try:
|
|
53
|
+
# Get all attributes for the queue
|
|
54
|
+
attrs_response = client.get_queue_attributes(
|
|
55
|
+
QueueUrl=url, AttributeNames=["All"]
|
|
56
|
+
)
|
|
57
|
+
attrs = attrs_response.get("Attributes", {})
|
|
58
|
+
|
|
59
|
+
# Get tags
|
|
60
|
+
try:
|
|
61
|
+
tags_response = client.list_queue_tags(QueueUrl=url)
|
|
62
|
+
tags = tags_response.get("Tags", {})
|
|
63
|
+
except Exception:
|
|
64
|
+
tags = {}
|
|
65
|
+
|
|
66
|
+
queue_name = _extract_queue_name(url)
|
|
67
|
+
is_fifo = queue_name.endswith(".fifo") or attrs.get("FifoQueue") == "true"
|
|
68
|
+
|
|
69
|
+
queues.append(
|
|
70
|
+
{
|
|
71
|
+
"name": queue_name,
|
|
72
|
+
"url": url,
|
|
73
|
+
"type": "FIFO" if is_fifo else "Standard",
|
|
74
|
+
"approximateNumberOfMessages": int(
|
|
75
|
+
attrs.get("ApproximateNumberOfMessages", 0)
|
|
76
|
+
),
|
|
77
|
+
"approximateNumberOfMessagesNotVisible": int(
|
|
78
|
+
attrs.get("ApproximateNumberOfMessagesNotVisible", 0)
|
|
79
|
+
),
|
|
80
|
+
"approximateNumberOfMessagesDelayed": int(
|
|
81
|
+
attrs.get("ApproximateNumberOfMessagesDelayed", 0)
|
|
82
|
+
),
|
|
83
|
+
"visibilityTimeout": int(attrs.get("VisibilityTimeout", 30)),
|
|
84
|
+
"messageRetentionPeriod": int(
|
|
85
|
+
attrs.get("MessageRetentionPeriod", 345600)
|
|
86
|
+
),
|
|
87
|
+
"delaySeconds": int(attrs.get("DelaySeconds", 0)),
|
|
88
|
+
"redrivePolicy": _parse_redrive_policy(
|
|
89
|
+
attrs.get("RedrivePolicy")
|
|
90
|
+
),
|
|
91
|
+
"tags": tags,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
# Skip queues that fail to fetch attributes
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
return {"queues": queues}
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/queues")
|
|
104
|
+
def create_queue(body: CreateQueueRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
105
|
+
"""Create a new SQS queue."""
|
|
106
|
+
try:
|
|
107
|
+
client = get_client("sqs", endpoint_url)
|
|
108
|
+
|
|
109
|
+
queue_name = body.queue_name
|
|
110
|
+
is_fifo = body.queue_type == "FIFO"
|
|
111
|
+
|
|
112
|
+
if is_fifo and not queue_name.endswith(".fifo"):
|
|
113
|
+
queue_name = f"{queue_name}.fifo"
|
|
114
|
+
|
|
115
|
+
attributes: dict[str, str] = {}
|
|
116
|
+
|
|
117
|
+
if is_fifo:
|
|
118
|
+
attributes["FifoQueue"] = "true"
|
|
119
|
+
|
|
120
|
+
if body.content_based_deduplication:
|
|
121
|
+
if not is_fifo:
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=400,
|
|
124
|
+
detail="ContentBasedDeduplication is only valid for FIFO queues",
|
|
125
|
+
)
|
|
126
|
+
attributes["ContentBasedDeduplication"] = "true"
|
|
127
|
+
|
|
128
|
+
if body.visibility_timeout is not None:
|
|
129
|
+
attributes["VisibilityTimeout"] = str(body.visibility_timeout)
|
|
130
|
+
if body.message_retention_period is not None:
|
|
131
|
+
attributes["MessageRetentionPeriod"] = str(body.message_retention_period)
|
|
132
|
+
if body.delay_seconds is not None:
|
|
133
|
+
attributes["DelaySeconds"] = str(body.delay_seconds)
|
|
134
|
+
if body.maximum_message_size is not None:
|
|
135
|
+
attributes["MaximumMessageSize"] = str(body.maximum_message_size)
|
|
136
|
+
if body.receive_message_wait_time is not None:
|
|
137
|
+
attributes["ReceiveMessageWaitTime"] = str(body.receive_message_wait_time)
|
|
138
|
+
|
|
139
|
+
dlq_queue_name = None
|
|
140
|
+
|
|
141
|
+
if body.dlq_enabled and not body.redrive_policy:
|
|
142
|
+
dlq_suffix = "-dlq.fifo" if is_fifo else "-dlq"
|
|
143
|
+
dlq_queue_name = queue_name.removesuffix(".fifo") + dlq_suffix
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
dlq_url_response = client.get_queue_url(QueueName=dlq_queue_name)
|
|
147
|
+
dlq_url = dlq_url_response["QueueUrl"]
|
|
148
|
+
dlq_attrs_response = client.get_queue_attributes(
|
|
149
|
+
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
|
|
150
|
+
)
|
|
151
|
+
dlq_arn = dlq_attrs_response["Attributes"]["QueueArn"]
|
|
152
|
+
except client.exceptions.QueueDoesNotExist:
|
|
153
|
+
dlq_attributes: dict[str, str] = {}
|
|
154
|
+
if is_fifo:
|
|
155
|
+
dlq_attributes["FifoQueue"] = "true"
|
|
156
|
+
dlq_attributes["SqsManagedSseEnabled"] = "true"
|
|
157
|
+
|
|
158
|
+
dlq_response = client.create_queue(
|
|
159
|
+
QueueName=dlq_queue_name, Attributes=dlq_attributes
|
|
160
|
+
)
|
|
161
|
+
dlq_url = dlq_response["QueueUrl"]
|
|
162
|
+
dlq_attrs_response = client.get_queue_attributes(
|
|
163
|
+
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
|
|
164
|
+
)
|
|
165
|
+
dlq_arn = dlq_attrs_response["Attributes"]["QueueArn"]
|
|
166
|
+
|
|
167
|
+
redrive = {
|
|
168
|
+
"deadLetterTargetArn": dlq_arn,
|
|
169
|
+
"maxReceiveCount": body.max_receive_count,
|
|
170
|
+
}
|
|
171
|
+
attributes["RedrivePolicy"] = json.dumps(redrive)
|
|
172
|
+
elif body.redrive_policy:
|
|
173
|
+
attributes["RedrivePolicy"] = json.dumps(
|
|
174
|
+
body.redrive_policy.model_dump(by_alias=True)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not body.sqs_managed_sse_enabled:
|
|
178
|
+
attributes["SqsManagedSseEnabled"] = "false"
|
|
179
|
+
if body.kms_master_key_id:
|
|
180
|
+
attributes["KmsMasterKeyId"] = body.kms_master_key_id
|
|
181
|
+
else:
|
|
182
|
+
attributes["SqsManagedSseEnabled"] = "true"
|
|
183
|
+
|
|
184
|
+
create_kwargs: dict[str, Any] = {"QueueName": queue_name}
|
|
185
|
+
if attributes:
|
|
186
|
+
create_kwargs["Attributes"] = attributes
|
|
187
|
+
|
|
188
|
+
response = client.create_queue(**create_kwargs)
|
|
189
|
+
|
|
190
|
+
queue_url = response["QueueUrl"]
|
|
191
|
+
arn_response = client.get_queue_attributes(
|
|
192
|
+
QueueUrl=queue_url, AttributeNames=["QueueArn"]
|
|
193
|
+
)
|
|
194
|
+
queue_arn = arn_response["Attributes"]["QueueArn"]
|
|
195
|
+
|
|
196
|
+
if body.tags:
|
|
197
|
+
try:
|
|
198
|
+
client.tag_queue(QueueUrl=queue_url, Tags=body.tags)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
result: dict[str, str] = {
|
|
203
|
+
"queueName": queue_name,
|
|
204
|
+
"queueUrl": queue_url,
|
|
205
|
+
"queueArn": queue_arn,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if dlq_queue_name:
|
|
209
|
+
result["dlqQueueName"] = dlq_queue_name
|
|
210
|
+
|
|
211
|
+
return result
|
|
212
|
+
except HTTPException:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get("/queues/{queue_name}")
|
|
219
|
+
def get_queue_detail(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
220
|
+
"""Get detailed attributes and tags for a specific queue."""
|
|
221
|
+
try:
|
|
222
|
+
client = get_client("sqs", endpoint_url)
|
|
223
|
+
|
|
224
|
+
# Get queue URL from name
|
|
225
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
226
|
+
queue_url = url_response["QueueUrl"]
|
|
227
|
+
|
|
228
|
+
# Get all attributes
|
|
229
|
+
attrs_response = client.get_queue_attributes(
|
|
230
|
+
QueueUrl=queue_url, AttributeNames=["All"]
|
|
231
|
+
)
|
|
232
|
+
attrs = attrs_response.get("Attributes", {})
|
|
233
|
+
|
|
234
|
+
# Get tags
|
|
235
|
+
try:
|
|
236
|
+
tags_response = client.list_queue_tags(QueueUrl=queue_url)
|
|
237
|
+
tags = tags_response.get("Tags", {})
|
|
238
|
+
except Exception:
|
|
239
|
+
tags = {}
|
|
240
|
+
|
|
241
|
+
is_fifo = queue_name.endswith(".fifo") or attrs.get("FifoQueue") == "true"
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"name": queue_name,
|
|
245
|
+
"url": queue_url,
|
|
246
|
+
"arn": attrs.get("QueueArn"),
|
|
247
|
+
"type": "FIFO" if is_fifo else "Standard",
|
|
248
|
+
"approximateNumberOfMessages": int(
|
|
249
|
+
attrs.get("ApproximateNumberOfMessages", 0)
|
|
250
|
+
),
|
|
251
|
+
"approximateNumberOfMessagesNotVisible": int(
|
|
252
|
+
attrs.get("ApproximateNumberOfMessagesNotVisible", 0)
|
|
253
|
+
),
|
|
254
|
+
"approximateNumberOfMessagesDelayed": int(
|
|
255
|
+
attrs.get("ApproximateNumberOfMessagesDelayed", 0)
|
|
256
|
+
),
|
|
257
|
+
"visibilityTimeout": int(attrs.get("VisibilityTimeout", 30)),
|
|
258
|
+
"messageRetentionPeriod": int(attrs.get("MessageRetentionPeriod", 345600)),
|
|
259
|
+
"maximumMessageSize": int(attrs.get("MaximumMessageSize", 262144)),
|
|
260
|
+
"delaySeconds": int(attrs.get("DelaySeconds", 0)),
|
|
261
|
+
"redrivePolicy": _parse_redrive_policy(attrs.get("RedrivePolicy")),
|
|
262
|
+
"contentBasedDeduplication": attrs.get("ContentBasedDeduplication") == "true",
|
|
263
|
+
"tags": tags,
|
|
264
|
+
}
|
|
265
|
+
except client.exceptions.QueueDoesNotExist:
|
|
266
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.post("/queues/{queue_name}/messages")
|
|
272
|
+
def send_message(queue_name: str, body: SendMessageRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
273
|
+
"""Send a message to the queue."""
|
|
274
|
+
try:
|
|
275
|
+
client = get_client("sqs", endpoint_url)
|
|
276
|
+
|
|
277
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
278
|
+
queue_url = url_response["QueueUrl"]
|
|
279
|
+
|
|
280
|
+
send_kwargs: dict[str, Any] = {
|
|
281
|
+
"QueueUrl": queue_url,
|
|
282
|
+
"MessageBody": body.message_body,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if body.delay_seconds is not None:
|
|
286
|
+
send_kwargs["DelaySeconds"] = body.delay_seconds
|
|
287
|
+
|
|
288
|
+
if body.message_attributes:
|
|
289
|
+
attrs = {}
|
|
290
|
+
for key, value in body.message_attributes.items():
|
|
291
|
+
attrs[key] = {
|
|
292
|
+
"StringValue": str(value.get("stringValue", "")),
|
|
293
|
+
"DataType": value.get("dataType", "String"),
|
|
294
|
+
}
|
|
295
|
+
send_kwargs["MessageAttributes"] = attrs
|
|
296
|
+
|
|
297
|
+
if body.message_deduplication_id:
|
|
298
|
+
send_kwargs["MessageDeduplicationId"] = body.message_deduplication_id
|
|
299
|
+
if body.message_group_id:
|
|
300
|
+
send_kwargs["MessageGroupId"] = body.message_group_id
|
|
301
|
+
|
|
302
|
+
response = client.send_message(**send_kwargs)
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
"messageId": response["MessageId"],
|
|
306
|
+
"md5OfMessageBody": response["MD5OfMessageBody"],
|
|
307
|
+
"sequenceNumber": response.get("SequenceNumber"),
|
|
308
|
+
}
|
|
309
|
+
except client.exceptions.QueueDoesNotExist:
|
|
310
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.get("/queues/{queue_name}/messages")
|
|
316
|
+
def receive_messages(
|
|
317
|
+
queue_name: str,
|
|
318
|
+
max_messages: int = Query(10, ge=1, le=10),
|
|
319
|
+
visibility_timeout: int = Query(0, ge=0, le=43200),
|
|
320
|
+
endpoint_url: str | None = Depends(get_endpoint_url),
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""Receive messages from the queue.
|
|
323
|
+
|
|
324
|
+
Use visibility_timeout=0 to peek without consuming messages.
|
|
325
|
+
Use visibility_timeout > 0 to prevent redelivery during inspection.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
client = get_client("sqs", endpoint_url)
|
|
329
|
+
|
|
330
|
+
# Get queue URL from name
|
|
331
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
332
|
+
queue_url = url_response["QueueUrl"]
|
|
333
|
+
|
|
334
|
+
response = client.receive_message(
|
|
335
|
+
QueueUrl=queue_url,
|
|
336
|
+
MaxNumberOfMessages=max_messages,
|
|
337
|
+
VisibilityTimeout=visibility_timeout,
|
|
338
|
+
MessageAttributeNames=["All"],
|
|
339
|
+
AttributeNames=["All"],
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
messages = response.get("Messages", [])
|
|
343
|
+
|
|
344
|
+
# Structure the messages for the frontend
|
|
345
|
+
formatted_messages = []
|
|
346
|
+
for msg in messages:
|
|
347
|
+
formatted_messages.append(
|
|
348
|
+
{
|
|
349
|
+
"messageId": msg.get("MessageId"),
|
|
350
|
+
"receiptHandle": msg.get("ReceiptHandle"),
|
|
351
|
+
"body": msg.get("Body"),
|
|
352
|
+
"md5OfBody": msg.get("MD5OfBody"),
|
|
353
|
+
"attributes": msg.get("Attributes", {}),
|
|
354
|
+
"messageAttributes": msg.get("MessageAttributes", {}),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return {"messages": formatted_messages}
|
|
359
|
+
except client.exceptions.QueueDoesNotExist:
|
|
360
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
361
|
+
except Exception as e:
|
|
362
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@router.delete("/queues/{queue_name}/messages")
|
|
366
|
+
def delete_message(queue_name: str, receipt_handle: str = Query(...), endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
|
|
367
|
+
"""Delete a message from the queue using its receipt handle."""
|
|
368
|
+
try:
|
|
369
|
+
client = get_client("sqs", endpoint_url)
|
|
370
|
+
|
|
371
|
+
# Get queue URL from name
|
|
372
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
373
|
+
queue_url = url_response["QueueUrl"]
|
|
374
|
+
|
|
375
|
+
# Decode receipt handle (it may be URL-encoded)
|
|
376
|
+
decoded_handle = unquote(receipt_handle)
|
|
377
|
+
|
|
378
|
+
client.delete_message(QueueUrl=queue_url, ReceiptHandle=decoded_handle)
|
|
379
|
+
|
|
380
|
+
return Response(status_code=204)
|
|
381
|
+
except client.exceptions.QueueDoesNotExist:
|
|
382
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
383
|
+
except client.exceptions.ReceiptHandleIsInvalid:
|
|
384
|
+
raise HTTPException(status_code=400, detail="Receipt handle is invalid or expired")
|
|
385
|
+
except Exception as e:
|
|
386
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@router.post("/queues/{queue_name}/purge")
|
|
390
|
+
def purge_queue(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
391
|
+
"""Purge all messages from the queue.
|
|
392
|
+
|
|
393
|
+
Note: Can only be called once every 60 seconds.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
client = get_client("sqs", endpoint_url)
|
|
397
|
+
|
|
398
|
+
# Get queue URL from name
|
|
399
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
400
|
+
queue_url = url_response["QueueUrl"]
|
|
401
|
+
|
|
402
|
+
client.purge_queue(QueueUrl=queue_url)
|
|
403
|
+
|
|
404
|
+
return {"success": True, "message": f"Queue {queue_name} purge initiated"}
|
|
405
|
+
except client.exceptions.QueueDoesNotExist:
|
|
406
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
407
|
+
except client.exceptions.PurgeQueueInProgress:
|
|
408
|
+
raise HTTPException(
|
|
409
|
+
status_code=409,
|
|
410
|
+
detail="Purge already in progress. Wait 60 seconds before purging again.",
|
|
411
|
+
)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@router.delete("/queues/{queue_name}")
|
|
417
|
+
def delete_queue(queue_name: str, endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
|
|
418
|
+
"""Delete an SQS queue.
|
|
419
|
+
|
|
420
|
+
Permanently deletes the queue and all its messages.
|
|
421
|
+
"""
|
|
422
|
+
try:
|
|
423
|
+
client = get_client("sqs", endpoint_url)
|
|
424
|
+
|
|
425
|
+
# Get queue URL from name
|
|
426
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
427
|
+
queue_url = url_response["QueueUrl"]
|
|
428
|
+
|
|
429
|
+
client.delete_queue(QueueUrl=queue_url)
|
|
430
|
+
|
|
431
|
+
return Response(status_code=204)
|
|
432
|
+
except client.exceptions.QueueDoesNotExist:
|
|
433
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
434
|
+
except Exception as e:
|
|
435
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@router.put("/queues/{queue_name}/attributes")
|
|
439
|
+
def update_queue_attributes(queue_name: str, body: UpdateAttributesRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
440
|
+
"""Update queue attributes."""
|
|
441
|
+
try:
|
|
442
|
+
client = get_client("sqs", endpoint_url)
|
|
443
|
+
|
|
444
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
445
|
+
queue_url = url_response["QueueUrl"]
|
|
446
|
+
|
|
447
|
+
attributes: dict[str, str] = {}
|
|
448
|
+
|
|
449
|
+
if body.visibility_timeout is not None:
|
|
450
|
+
attributes["VisibilityTimeout"] = str(body.visibility_timeout)
|
|
451
|
+
if body.message_retention_period is not None:
|
|
452
|
+
attributes["MessageRetentionPeriod"] = str(body.message_retention_period)
|
|
453
|
+
if body.delay_seconds is not None:
|
|
454
|
+
attributes["DelaySeconds"] = str(body.delay_seconds)
|
|
455
|
+
if body.maximum_message_size is not None:
|
|
456
|
+
attributes["MaximumMessageSize"] = str(body.maximum_message_size)
|
|
457
|
+
if body.receive_message_wait_time is not None:
|
|
458
|
+
attributes["ReceiveMessageWaitTime"] = str(body.receive_message_wait_time)
|
|
459
|
+
|
|
460
|
+
if not attributes:
|
|
461
|
+
raise HTTPException(status_code=400, detail="No attributes provided")
|
|
462
|
+
|
|
463
|
+
client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes)
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
"success": True,
|
|
467
|
+
"message": f"Queue {queue_name} attributes updated successfully",
|
|
468
|
+
}
|
|
469
|
+
except client.exceptions.QueueDoesNotExist:
|
|
470
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
471
|
+
except HTTPException:
|
|
472
|
+
raise
|
|
473
|
+
except Exception as e:
|
|
474
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@router.post("/queues/{queue_name}/messages/batch")
|
|
478
|
+
def send_messages_batch(queue_name: str, body: BatchSendRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
479
|
+
"""Send multiple messages to the queue in one operation (max 10)."""
|
|
480
|
+
try:
|
|
481
|
+
client = get_client("sqs", endpoint_url)
|
|
482
|
+
|
|
483
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
484
|
+
queue_url = url_response["QueueUrl"]
|
|
485
|
+
|
|
486
|
+
batch_entries = []
|
|
487
|
+
for entry in body.entries:
|
|
488
|
+
batch_entry: dict[str, Any] = {
|
|
489
|
+
"Id": entry.id,
|
|
490
|
+
"MessageBody": entry.message_body,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if entry.delay_seconds is not None:
|
|
494
|
+
batch_entry["DelaySeconds"] = entry.delay_seconds
|
|
495
|
+
if entry.message_deduplication_id:
|
|
496
|
+
batch_entry["MessageDeduplicationId"] = entry.message_deduplication_id
|
|
497
|
+
if entry.message_group_id:
|
|
498
|
+
batch_entry["MessageGroupId"] = entry.message_group_id
|
|
499
|
+
|
|
500
|
+
batch_entries.append(batch_entry)
|
|
501
|
+
|
|
502
|
+
response = client.send_message_batch(
|
|
503
|
+
QueueUrl=queue_url, Entries=batch_entries
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
successful = [
|
|
507
|
+
{"id": entry["Id"], "messageId": entry["MessageId"]}
|
|
508
|
+
for entry in response.get("Successful", [])
|
|
509
|
+
]
|
|
510
|
+
failed = [
|
|
511
|
+
{
|
|
512
|
+
"id": entry["Id"],
|
|
513
|
+
"code": entry.get("Code", ""),
|
|
514
|
+
"message": entry.get("Message", ""),
|
|
515
|
+
}
|
|
516
|
+
for entry in response.get("Failed", [])
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
return {"successful": successful, "failed": failed}
|
|
520
|
+
except client.exceptions.QueueDoesNotExist:
|
|
521
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
522
|
+
except Exception as e:
|
|
523
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@router.delete("/queues/{queue_name}/messages/batch")
|
|
527
|
+
def delete_messages_batch(queue_name: str, body: BatchDeleteRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> Response:
|
|
528
|
+
"""Delete multiple messages from the queue in one operation (max 10)."""
|
|
529
|
+
try:
|
|
530
|
+
client = get_client("sqs", endpoint_url)
|
|
531
|
+
|
|
532
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
533
|
+
queue_url = url_response["QueueUrl"]
|
|
534
|
+
|
|
535
|
+
batch_entries = []
|
|
536
|
+
for idx, receipt_handle in enumerate(body.receipt_handles):
|
|
537
|
+
decoded_handle = unquote(receipt_handle)
|
|
538
|
+
batch_entries.append(
|
|
539
|
+
{"Id": str(idx), "ReceiptHandle": decoded_handle}
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
client.delete_message_batch(QueueUrl=queue_url, Entries=batch_entries)
|
|
543
|
+
|
|
544
|
+
return Response(status_code=204)
|
|
545
|
+
except client.exceptions.QueueDoesNotExist:
|
|
546
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
547
|
+
except Exception as e:
|
|
548
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@router.put("/queues/{queue_name}/redrive-policy")
|
|
552
|
+
def update_redrive_policy(queue_name: str, body: UpdateRedrivePolicyRequest, endpoint_url: str | None = Depends(get_endpoint_url)) -> dict[str, Any]:
|
|
553
|
+
"""Update the dead-letter queue redrive policy."""
|
|
554
|
+
try:
|
|
555
|
+
client = get_client("sqs", endpoint_url)
|
|
556
|
+
|
|
557
|
+
url_response = client.get_queue_url(QueueName=queue_name)
|
|
558
|
+
queue_url = url_response["QueueUrl"]
|
|
559
|
+
|
|
560
|
+
redrive_policy = {
|
|
561
|
+
"deadLetterTargetArn": body.dead_letter_target_arn,
|
|
562
|
+
"maxReceiveCount": body.max_receive_count,
|
|
563
|
+
}
|
|
564
|
+
attributes = {"RedrivePolicy": json.dumps(redrive_policy)}
|
|
565
|
+
|
|
566
|
+
client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes)
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
"success": True,
|
|
570
|
+
"message": f"Queue {queue_name} redrive policy updated successfully",
|
|
571
|
+
}
|
|
572
|
+
except client.exceptions.QueueDoesNotExist:
|
|
573
|
+
raise HTTPException(status_code=404, detail=f"Queue {queue_name} not found")
|
|
574
|
+
except Exception as e:
|
|
575
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -3,28 +3,14 @@
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
-
from pydantic import BaseModel
|
|
7
6
|
|
|
8
7
|
from backend.aws_client import get_client
|
|
9
8
|
from backend.routes.common import get_endpoint_url
|
|
9
|
+
from backend.schemas.tags import BulkDeleteRequest, BulkTagRequest, TagUpdateRequest
|
|
10
10
|
|
|
11
11
|
router = APIRouter()
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class TagUpdateRequest(BaseModel):
|
|
15
|
-
tags: dict[str, str]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class BulkTagRequest(BaseModel):
|
|
19
|
-
action: str # "add" or "remove"
|
|
20
|
-
tags: dict[str, str]
|
|
21
|
-
resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class BulkDeleteRequest(BaseModel):
|
|
25
|
-
resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
|
|
26
|
-
|
|
27
|
-
|
|
28
14
|
# --- Tag getters: (service, type) -> callable(client, resource_id) -> dict ---
|
|
29
15
|
|
|
30
16
|
def _get_tags_s3_bucket(client: Any, resource_id: str) -> dict[str, str]:
|