stackport 0.1.9__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stackport-0.1.9/stackport.egg-info → stackport-0.2.0}/PKG-INFO +1 -1
- {stackport-0.1.9 → stackport-0.2.0}/backend/cache.py +7 -2
- {stackport-0.1.9 → stackport-0.2.0}/backend/config.py +5 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/main.py +2 -1
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/stats.py +8 -3
- stackport-0.2.0/backend/routes/tags.py +549 -0
- {stackport-0.1.9 → stackport-0.2.0}/pyproject.toml +1 -1
- {stackport-0.1.9 → stackport-0.2.0/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/SOURCES.txt +6 -4
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_config.py +30 -0
- stackport-0.2.0/tests/test_tags_routes.py +826 -0
- stackport-0.2.0/ui/dist/assets/index-BauDt01Y.css +1 -0
- stackport-0.2.0/ui/dist/assets/index-CMbP0j4Q.js +563 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/index.html +2 -2
- stackport-0.1.9/ui/dist/assets/index-CWKwl2Ak.css +0 -1
- stackport-0.1.9/ui/dist/assets/index-Det-eQBZ.js +0 -552
- {stackport-0.1.9 → stackport-0.2.0}/LICENSE +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/MANIFEST.in +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/README.md +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/__init__.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/aws_client.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/cli.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/__init__.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/common.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/dynamodb.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/ec2.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/endpoints.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/iam.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/logs.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/resources.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/s3.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/secretsmanager.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/routes/sqs.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/backend/websocket.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/setup.cfg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_cache.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_cli.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_client.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_ec2_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_endpoints.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_iam_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_lambda_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_logs_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_registries.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_s3_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_secretsmanager_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_sqs_routes.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/tests/test_websocket.py +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/favicon.png +0 -0
- {stackport-0.1.9 → stackport-0.2.0}/ui/dist/favicon.svg +0 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
import time
|
|
3
3
|
|
|
4
|
+
from backend.config import STACKPORT_CACHE_TTL
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
class TTLCache:
|
|
6
|
-
def __init__(self):
|
|
8
|
+
def __init__(self, default_ttl: int = STACKPORT_CACHE_TTL):
|
|
7
9
|
self._store: dict = {}
|
|
8
10
|
self._lock = threading.Lock()
|
|
11
|
+
self._default_ttl = default_ttl
|
|
9
12
|
|
|
10
13
|
def get(self, key: str):
|
|
11
14
|
with self._lock:
|
|
@@ -16,7 +19,9 @@ class TTLCache:
|
|
|
16
19
|
del self._store[key]
|
|
17
20
|
return None
|
|
18
21
|
|
|
19
|
-
def set(self, key: str, value, ttl: float =
|
|
22
|
+
def set(self, key: str, value, ttl: float | None = None):
|
|
23
|
+
if ttl is None:
|
|
24
|
+
ttl = self._default_ttl
|
|
20
25
|
with self._lock:
|
|
21
26
|
self._store[key] = (value, time.time() + ttl)
|
|
22
27
|
|
|
@@ -18,6 +18,11 @@ STACKPORT_SERVICES: str = os.environ.get(
|
|
|
18
18
|
)
|
|
19
19
|
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
20
20
|
|
|
21
|
+
# Probe and cache configuration
|
|
22
|
+
STACKPORT_PROBE_TIMEOUT: int = int(os.environ.get("STACKPORT_PROBE_TIMEOUT", "5"))
|
|
23
|
+
STACKPORT_CACHE_TTL: int = int(os.environ.get("STACKPORT_CACHE_TTL", "5"))
|
|
24
|
+
STACKPORT_PROBE_WORKERS: int = int(os.environ.get("STACKPORT_PROBE_WORKERS", "10"))
|
|
25
|
+
|
|
21
26
|
_MIB: int = 1024 * 1024
|
|
22
27
|
|
|
23
28
|
# Default max upload: 100 MiB (whole mebibytes; STACKPORT_S3_MAX_UPLOAD_MB).
|
|
@@ -10,7 +10,7 @@ from fastapi.responses import FileResponse
|
|
|
10
10
|
from fastapi.staticfiles import StaticFiles
|
|
11
11
|
|
|
12
12
|
from backend.config import LOG_LEVEL, STACKPORT_PORT
|
|
13
|
-
from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats
|
|
13
|
+
from backend.routes import dynamodb, ec2, endpoints, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats, tags
|
|
14
14
|
from backend.websocket import probe_loop, websocket_endpoint
|
|
15
15
|
|
|
16
16
|
|
|
@@ -61,6 +61,7 @@ app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
|
|
|
61
61
|
app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
|
|
62
62
|
app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
|
|
63
63
|
app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
|
|
64
|
+
app.include_router(tags.router, prefix="/api", tags=["tags"])
|
|
64
65
|
app.include_router(resources.router, prefix="/api")
|
|
65
66
|
|
|
66
67
|
|
|
@@ -9,7 +9,12 @@ from backend.aws_client import get_client
|
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
from backend.cache import cache
|
|
12
|
-
from backend.config import
|
|
12
|
+
from backend.config import (
|
|
13
|
+
AWS_ENDPOINT_URL,
|
|
14
|
+
AWS_REGION,
|
|
15
|
+
STACKPORT_PROBE_WORKERS,
|
|
16
|
+
STACKPORT_SERVICES,
|
|
17
|
+
)
|
|
13
18
|
from backend.routes.common import get_endpoint_url
|
|
14
19
|
|
|
15
20
|
router = APIRouter()
|
|
@@ -154,7 +159,7 @@ def get_stats(endpoint_url: str = Depends(get_endpoint_url)):
|
|
|
154
159
|
services: dict = {}
|
|
155
160
|
total_resources = 0
|
|
156
161
|
|
|
157
|
-
with ThreadPoolExecutor(max_workers=min(len(enabled_services),
|
|
162
|
+
with ThreadPoolExecutor(max_workers=min(len(enabled_services), STACKPORT_PROBE_WORKERS)) as executor:
|
|
158
163
|
futures = {executor.submit(_probe_service, svc, endpoint_url): svc for svc in enabled_services}
|
|
159
164
|
for future in as_completed(futures):
|
|
160
165
|
svc_name, result = future.result()
|
|
@@ -169,5 +174,5 @@ def get_stats(endpoint_url: str = Depends(get_endpoint_url)):
|
|
|
169
174
|
"total_resources": total_resources,
|
|
170
175
|
"uptime_seconds": round(time.time() - _start_time, 1),
|
|
171
176
|
}
|
|
172
|
-
cache.set(cache_key, response
|
|
177
|
+
cache.set(cache_key, response)
|
|
173
178
|
return response
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Tag management routes for all supported services."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from backend.aws_client import get_client
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TagUpdateRequest(BaseModel):
|
|
14
|
+
tags: dict[str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BulkTagRequest(BaseModel):
|
|
18
|
+
action: str # "add" or "remove"
|
|
19
|
+
tags: dict[str, str]
|
|
20
|
+
resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BulkDeleteRequest(BaseModel):
|
|
24
|
+
resources: list[dict[str, str]] # [{"service": "s3", "type": "buckets", "id": "my-bucket"}, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- Tag getters: (service, type) -> callable(client, resource_id) -> dict ---
|
|
28
|
+
|
|
29
|
+
def _get_tags_s3_bucket(client: Any, resource_id: str) -> dict[str, str]:
|
|
30
|
+
try:
|
|
31
|
+
resp = client.get_bucket_tagging(Bucket=resource_id)
|
|
32
|
+
return {t["Key"]: t["Value"] for t in resp.get("TagSet", [])}
|
|
33
|
+
except client.exceptions.ClientError:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_tags_sqs_queue(client: Any, resource_id: str) -> dict[str, str]:
|
|
38
|
+
url_resp = client.get_queue_url(QueueName=resource_id)
|
|
39
|
+
queue_url = url_resp["QueueUrl"]
|
|
40
|
+
resp = client.list_queue_tags(QueueUrl=queue_url)
|
|
41
|
+
return resp.get("Tags", {})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_tags_lambda(client: Any, resource_id: str) -> dict[str, str]:
|
|
45
|
+
resp = client.get_function(FunctionName=resource_id)
|
|
46
|
+
return resp.get("Tags", {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_tags_dynamodb(client: Any, resource_id: str) -> dict[str, str]:
|
|
50
|
+
resp = client.describe_table(TableName=resource_id)
|
|
51
|
+
arn = resp["Table"]["TableArn"]
|
|
52
|
+
tag_resp = client.list_tags_of_resource(ResourceArn=arn)
|
|
53
|
+
return {t["Key"]: t["Value"] for t in tag_resp.get("Tags", [])}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_tags_secretsmanager(client: Any, resource_id: str) -> dict[str, str]:
|
|
57
|
+
resp = client.describe_secret(SecretId=resource_id)
|
|
58
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_tags_logs(client: Any, resource_id: str) -> dict[str, str]:
|
|
62
|
+
resp = client.list_tags_for_resource(resourceArn=resource_id)
|
|
63
|
+
return resp.get("tags", {})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_tags_ec2_instance(client: Any, resource_id: str) -> dict[str, str]:
|
|
67
|
+
resp = client.describe_tags(Filters=[{"Name": "resource-id", "Values": [resource_id]}])
|
|
68
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_tags_iam_user(client: Any, resource_id: str) -> dict[str, str]:
|
|
72
|
+
resp = client.list_user_tags(UserName=resource_id)
|
|
73
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_tags_iam_role(client: Any, resource_id: str) -> dict[str, str]:
|
|
77
|
+
resp = client.list_role_tags(RoleName=resource_id)
|
|
78
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_tags_iam_policy(client: Any, resource_id: str) -> dict[str, str]:
|
|
82
|
+
resp = client.list_policy_tags(PolicyArn=resource_id)
|
|
83
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_tags_rds_db_instance(client: Any, resource_id: str) -> dict[str, str]:
|
|
87
|
+
resp = client.describe_db_instances(DBInstanceIdentifier=resource_id)
|
|
88
|
+
arn = resp["DBInstances"][0]["DBInstanceArn"]
|
|
89
|
+
tag_resp = client.list_tags_for_resource(ResourceName=arn)
|
|
90
|
+
return {t["Key"]: t["Value"] for t in tag_resp.get("TagList", [])}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_tags_rds_db_cluster(client: Any, resource_id: str) -> dict[str, str]:
|
|
94
|
+
resp = client.describe_db_clusters(DBClusterIdentifier=resource_id)
|
|
95
|
+
arn = resp["DBClusters"][0]["DBClusterArn"]
|
|
96
|
+
tag_resp = client.list_tags_for_resource(ResourceName=arn)
|
|
97
|
+
return {t["Key"]: t["Value"] for t in tag_resp.get("TagList", [])}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_tags_sns_topic(client: Any, resource_id: str) -> dict[str, str]:
|
|
101
|
+
resp = client.list_tags_for_resource(ResourceArn=resource_id)
|
|
102
|
+
return {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_tags_kms_key(client: Any, resource_id: str) -> dict[str, str]:
|
|
106
|
+
resp = client.list_resource_tags(KeyId=resource_id)
|
|
107
|
+
return {t["TagKey"]: t["TagValue"] for t in resp.get("Tags", [])}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_tags_ecr_repository(client: Any, resource_id: str) -> dict[str, str]:
|
|
111
|
+
repo = client.describe_repositories(repositoryNames=[resource_id])
|
|
112
|
+
arn = repo["repositories"][0]["repositoryArn"]
|
|
113
|
+
resp = client.list_tags_for_resource(resourceArn=arn)
|
|
114
|
+
return {t["Key"]: t["Value"] for t in resp.get("tags", [])}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_tags_cloudformation_stack(client: Any, resource_id: str) -> dict[str, str]:
|
|
118
|
+
resp = client.describe_stacks(StackName=resource_id)
|
|
119
|
+
return {t["Key"]: t["Value"] for t in resp["Stacks"][0].get("Tags", [])}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_tags_stepfunctions(client: Any, resource_id: str) -> dict[str, str]:
|
|
123
|
+
resp = client.list_tags_for_resource(resourceArn=resource_id)
|
|
124
|
+
return {t["key"]: t["value"] for t in resp.get("tags", [])}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_tags_kinesis_stream(client: Any, resource_id: str) -> dict[str, str]:
|
|
128
|
+
resp = client.list_tags_for_stream(StreamName=resource_id)
|
|
129
|
+
tags = {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
130
|
+
while resp.get("HasMoreTags"):
|
|
131
|
+
resp = client.list_tags_for_stream(
|
|
132
|
+
StreamName=resource_id,
|
|
133
|
+
ExclusiveStartTagKey=resp["Tags"][-1]["Key"],
|
|
134
|
+
)
|
|
135
|
+
tags.update({t["Key"]: t["Value"] for t in resp.get("Tags", [])})
|
|
136
|
+
return tags
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _get_tags_ssm_parameter(client: Any, resource_id: str) -> dict[str, str]:
|
|
140
|
+
resp = client.list_tags_for_resource(ResourceType="Parameter", ResourceId=resource_id)
|
|
141
|
+
return {t["Key"]: t["Value"] for t in resp.get("TagList", [])}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _get_tags_elbv2_load_balancer(client: Any, resource_id: str) -> dict[str, str]:
|
|
145
|
+
resp = client.describe_tags(ResourceArns=[resource_id])
|
|
146
|
+
for desc in resp.get("TagDescriptions", []):
|
|
147
|
+
if desc["ResourceArn"] == resource_id:
|
|
148
|
+
return {t["Key"]: t["Value"] for t in desc.get("Tags", [])}
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_tags_elasticache_cluster(client: Any, resource_id: str) -> dict[str, str]:
|
|
153
|
+
resp = client.describe_cache_clusters(CacheClusterId=resource_id)
|
|
154
|
+
arn = resp["CacheClusters"][0]["ARN"]
|
|
155
|
+
tag_resp = client.list_tags_for_resource(ResourceName=arn)
|
|
156
|
+
return {t["Key"]: t["Value"] for t in tag_resp.get("TagList", [])}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- Tag setters: (service, type) -> callable(client, resource_id, tags) ---
|
|
160
|
+
|
|
161
|
+
def _set_tags_s3_bucket(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
162
|
+
tag_set = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
163
|
+
if tag_set:
|
|
164
|
+
client.put_bucket_tagging(Bucket=resource_id, Tagging={"TagSet": tag_set})
|
|
165
|
+
else:
|
|
166
|
+
client.delete_bucket_tagging(Bucket=resource_id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _set_tags_sqs_queue(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
170
|
+
url_resp = client.get_queue_url(QueueName=resource_id)
|
|
171
|
+
queue_url = url_resp["QueueUrl"]
|
|
172
|
+
# SQS: untag all existing, then tag with new set
|
|
173
|
+
existing = client.list_queue_tags(QueueUrl=queue_url).get("Tags", {})
|
|
174
|
+
if existing:
|
|
175
|
+
client.untag_queue(QueueUrl=queue_url, TagKeys=list(existing.keys()))
|
|
176
|
+
if tags:
|
|
177
|
+
client.tag_queue(QueueUrl=queue_url, Tags=tags)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _set_tags_lambda(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
181
|
+
resp = client.get_function(FunctionName=resource_id)
|
|
182
|
+
arn = resp["Configuration"]["FunctionArn"]
|
|
183
|
+
existing = resp.get("Tags", {})
|
|
184
|
+
if existing:
|
|
185
|
+
client.untag_resource(Resource=arn, TagKeys=list(existing.keys()))
|
|
186
|
+
if tags:
|
|
187
|
+
client.tag_resource(Resource=arn, Tags=tags)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _set_tags_dynamodb(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
191
|
+
resp = client.describe_table(TableName=resource_id)
|
|
192
|
+
arn = resp["Table"]["TableArn"]
|
|
193
|
+
existing = client.list_tags_of_resource(ResourceArn=arn)
|
|
194
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
195
|
+
if existing_keys:
|
|
196
|
+
client.untag_resource(ResourceArn=arn, TagKeys=existing_keys)
|
|
197
|
+
if tags:
|
|
198
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
199
|
+
client.tag_resource(ResourceArn=arn, Tags=tag_list)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _set_tags_secretsmanager(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
203
|
+
resp = client.describe_secret(SecretId=resource_id)
|
|
204
|
+
existing = {t["Key"]: t["Value"] for t in resp.get("Tags", [])}
|
|
205
|
+
if existing:
|
|
206
|
+
client.untag_resource(SecretId=resource_id, TagKeys=list(existing.keys()))
|
|
207
|
+
if tags:
|
|
208
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
209
|
+
client.tag_resource(SecretId=resource_id, Tags=tag_list)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _set_tags_logs(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
213
|
+
# resource_id is the log group ARN or name
|
|
214
|
+
# First get existing tags to remove them
|
|
215
|
+
try:
|
|
216
|
+
existing = client.list_tags_for_resource(resourceArn=resource_id)
|
|
217
|
+
existing_keys = list(existing.get("tags", {}).keys())
|
|
218
|
+
if existing_keys:
|
|
219
|
+
client.untag_resource(resourceArn=resource_id, tagKeys=existing_keys)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
if tags:
|
|
223
|
+
client.tag_resource(resourceArn=resource_id, tags=tags)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _set_tags_ec2_instance(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
227
|
+
# Remove all existing tags
|
|
228
|
+
existing = client.describe_tags(Filters=[{"Name": "resource-id", "Values": [resource_id]}])
|
|
229
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
230
|
+
if existing_keys:
|
|
231
|
+
client.delete_tags(Resources=[resource_id], Tags=[{"Key": k} for k in existing_keys])
|
|
232
|
+
if tags:
|
|
233
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
234
|
+
client.create_tags(Resources=[resource_id], Tags=tag_list)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _set_tags_iam_user(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
238
|
+
existing = client.list_user_tags(UserName=resource_id)
|
|
239
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
240
|
+
if existing_keys:
|
|
241
|
+
client.untag_user(UserName=resource_id, TagKeys=existing_keys)
|
|
242
|
+
if tags:
|
|
243
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
244
|
+
client.tag_user(UserName=resource_id, Tags=tag_list)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _set_tags_iam_role(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
248
|
+
existing = client.list_role_tags(RoleName=resource_id)
|
|
249
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
250
|
+
if existing_keys:
|
|
251
|
+
client.untag_role(RoleName=resource_id, TagKeys=existing_keys)
|
|
252
|
+
if tags:
|
|
253
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
254
|
+
client.tag_role(RoleName=resource_id, Tags=tag_list)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _set_tags_iam_policy(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
258
|
+
existing = client.list_policy_tags(PolicyArn=resource_id)
|
|
259
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
260
|
+
if existing_keys:
|
|
261
|
+
client.untag_policy(PolicyArn=resource_id, TagKeys=existing_keys)
|
|
262
|
+
if tags:
|
|
263
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
264
|
+
client.tag_policy(PolicyArn=resource_id, Tags=tag_list)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _set_tags_rds_db_instance(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
268
|
+
resp = client.describe_db_instances(DBInstanceIdentifier=resource_id)
|
|
269
|
+
arn = resp["DBInstances"][0]["DBInstanceArn"]
|
|
270
|
+
existing = client.list_tags_for_resource(ResourceName=arn)
|
|
271
|
+
existing_keys = [t["Key"] for t in existing.get("TagList", [])]
|
|
272
|
+
if existing_keys:
|
|
273
|
+
client.remove_tags_from_resource(ResourceName=arn, TagKeys=existing_keys)
|
|
274
|
+
if tags:
|
|
275
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
276
|
+
client.add_tags_to_resource(ResourceName=arn, Tags=tag_list)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _set_tags_rds_db_cluster(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
280
|
+
resp = client.describe_db_clusters(DBClusterIdentifier=resource_id)
|
|
281
|
+
arn = resp["DBClusters"][0]["DBClusterArn"]
|
|
282
|
+
existing = client.list_tags_for_resource(ResourceName=arn)
|
|
283
|
+
existing_keys = [t["Key"] for t in existing.get("TagList", [])]
|
|
284
|
+
if existing_keys:
|
|
285
|
+
client.remove_tags_from_resource(ResourceName=arn, TagKeys=existing_keys)
|
|
286
|
+
if tags:
|
|
287
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
288
|
+
client.add_tags_to_resource(ResourceName=arn, Tags=tag_list)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _set_tags_sns_topic(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
292
|
+
existing = client.list_tags_for_resource(ResourceArn=resource_id)
|
|
293
|
+
existing_keys = [t["Key"] for t in existing.get("Tags", [])]
|
|
294
|
+
if existing_keys:
|
|
295
|
+
client.untag_resource(ResourceArn=resource_id, TagKeys=existing_keys)
|
|
296
|
+
if tags:
|
|
297
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
298
|
+
client.tag_resource(ResourceArn=resource_id, Tags=tag_list)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _set_tags_kms_key(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
302
|
+
existing = client.list_resource_tags(KeyId=resource_id)
|
|
303
|
+
existing_keys = [t["TagKey"] for t in existing.get("Tags", [])]
|
|
304
|
+
if existing_keys:
|
|
305
|
+
client.untag_resource(KeyId=resource_id, TagKeys=existing_keys)
|
|
306
|
+
if tags:
|
|
307
|
+
tag_list = [{"TagKey": k, "TagValue": v} for k, v in tags.items()]
|
|
308
|
+
client.tag_resource(KeyId=resource_id, Tags=tag_list)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _set_tags_ecr_repository(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
312
|
+
repo = client.describe_repositories(repositoryNames=[resource_id])
|
|
313
|
+
arn = repo["repositories"][0]["repositoryArn"]
|
|
314
|
+
existing = client.list_tags_for_resource(resourceArn=arn)
|
|
315
|
+
existing_keys = [t["Key"] for t in existing.get("tags", [])]
|
|
316
|
+
if existing_keys:
|
|
317
|
+
client.untag_resource(resourceArn=arn, tagKeys=existing_keys)
|
|
318
|
+
if tags:
|
|
319
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
320
|
+
client.tag_resource(resourceArn=arn, tags=tag_list)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _set_tags_stepfunctions(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
324
|
+
existing = client.list_tags_for_resource(resourceArn=resource_id)
|
|
325
|
+
existing_keys = [t["key"] for t in existing.get("tags", [])]
|
|
326
|
+
if existing_keys:
|
|
327
|
+
client.untag_resource(resourceArn=resource_id, tagKeys=existing_keys)
|
|
328
|
+
if tags:
|
|
329
|
+
tag_list = [{"key": k, "value": v} for k, v in tags.items()]
|
|
330
|
+
client.tag_resource(resourceArn=resource_id, tags=tag_list)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _set_tags_kinesis_stream(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
334
|
+
existing = _get_tags_kinesis_stream(client, resource_id)
|
|
335
|
+
if existing:
|
|
336
|
+
client.remove_tags_from_stream(StreamName=resource_id, TagKeys=list(existing.keys()))
|
|
337
|
+
if tags:
|
|
338
|
+
client.add_tags_to_stream(StreamName=resource_id, Tags=tags)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _set_tags_ssm_parameter(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
342
|
+
existing = client.list_tags_for_resource(ResourceType="Parameter", ResourceId=resource_id)
|
|
343
|
+
existing_keys = [t["Key"] for t in existing.get("TagList", [])]
|
|
344
|
+
if existing_keys:
|
|
345
|
+
client.remove_tags_from_resource(ResourceType="Parameter", ResourceId=resource_id, TagKeys=existing_keys)
|
|
346
|
+
if tags:
|
|
347
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
348
|
+
client.add_tags_to_resource(ResourceType="Parameter", ResourceId=resource_id, Tags=tag_list)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _set_tags_elbv2_load_balancer(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
352
|
+
existing = _get_tags_elbv2_load_balancer(client, resource_id)
|
|
353
|
+
if existing:
|
|
354
|
+
client.remove_tags(ResourceArns=[resource_id], TagKeys=list(existing.keys()))
|
|
355
|
+
if tags:
|
|
356
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
357
|
+
client.add_tags(ResourceArns=[resource_id], Tags=tag_list)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _set_tags_elasticache_cluster(client: Any, resource_id: str, tags: dict[str, str]) -> None:
|
|
361
|
+
resp = client.describe_cache_clusters(CacheClusterId=resource_id)
|
|
362
|
+
arn = resp["CacheClusters"][0]["ARN"]
|
|
363
|
+
existing = client.list_tags_for_resource(ResourceName=arn)
|
|
364
|
+
existing_keys = [t["Key"] for t in existing.get("TagList", [])]
|
|
365
|
+
if existing_keys:
|
|
366
|
+
client.remove_tags_from_resource(ResourceName=arn, TagKeys=existing_keys)
|
|
367
|
+
if tags:
|
|
368
|
+
tag_list = [{"Key": k, "Value": v} for k, v in tags.items()]
|
|
369
|
+
client.add_tags_to_resource(ResourceName=arn, Tags=tag_list)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --- Registries ---
|
|
373
|
+
|
|
374
|
+
TAG_GETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
375
|
+
("s3", "buckets"): ("s3", _get_tags_s3_bucket),
|
|
376
|
+
("sqs", "queues"): ("sqs", _get_tags_sqs_queue),
|
|
377
|
+
("lambda", "functions"): ("lambda", _get_tags_lambda),
|
|
378
|
+
("dynamodb", "tables"): ("dynamodb", _get_tags_dynamodb),
|
|
379
|
+
("secretsmanager", "secrets"): ("secretsmanager", _get_tags_secretsmanager),
|
|
380
|
+
("logs", "log_groups"): ("logs", _get_tags_logs),
|
|
381
|
+
("ec2", "instances"): ("ec2", _get_tags_ec2_instance),
|
|
382
|
+
("iam", "users"): ("iam", _get_tags_iam_user),
|
|
383
|
+
("iam", "roles"): ("iam", _get_tags_iam_role),
|
|
384
|
+
("iam", "policies"): ("iam", _get_tags_iam_policy),
|
|
385
|
+
("rds", "db_instances"): ("rds", _get_tags_rds_db_instance),
|
|
386
|
+
("rds", "db_clusters"): ("rds", _get_tags_rds_db_cluster),
|
|
387
|
+
("sns", "topics"): ("sns", _get_tags_sns_topic),
|
|
388
|
+
("kms", "keys"): ("kms", _get_tags_kms_key),
|
|
389
|
+
("ecr", "repositories"): ("ecr", _get_tags_ecr_repository),
|
|
390
|
+
("cloudformation", "stacks"): ("cloudformation", _get_tags_cloudformation_stack),
|
|
391
|
+
("stepfunctions", "state_machines"): ("stepfunctions", _get_tags_stepfunctions),
|
|
392
|
+
("kinesis", "streams"): ("kinesis", _get_tags_kinesis_stream),
|
|
393
|
+
("ssm", "parameters"): ("ssm", _get_tags_ssm_parameter),
|
|
394
|
+
("elasticloadbalancing", "load_balancers"): ("elbv2", _get_tags_elbv2_load_balancer),
|
|
395
|
+
("elasticache", "cache_clusters"): ("elasticache", _get_tags_elasticache_cluster),
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
TAG_SETTER_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
399
|
+
("s3", "buckets"): ("s3", _set_tags_s3_bucket),
|
|
400
|
+
("sqs", "queues"): ("sqs", _set_tags_sqs_queue),
|
|
401
|
+
("lambda", "functions"): ("lambda", _set_tags_lambda),
|
|
402
|
+
("dynamodb", "tables"): ("dynamodb", _set_tags_dynamodb),
|
|
403
|
+
("secretsmanager", "secrets"): ("secretsmanager", _set_tags_secretsmanager),
|
|
404
|
+
("logs", "log_groups"): ("logs", _set_tags_logs),
|
|
405
|
+
("ec2", "instances"): ("ec2", _set_tags_ec2_instance),
|
|
406
|
+
("iam", "users"): ("iam", _set_tags_iam_user),
|
|
407
|
+
("iam", "roles"): ("iam", _set_tags_iam_role),
|
|
408
|
+
("iam", "policies"): ("iam", _set_tags_iam_policy),
|
|
409
|
+
("rds", "db_instances"): ("rds", _set_tags_rds_db_instance),
|
|
410
|
+
("rds", "db_clusters"): ("rds", _set_tags_rds_db_cluster),
|
|
411
|
+
("sns", "topics"): ("sns", _set_tags_sns_topic),
|
|
412
|
+
("kms", "keys"): ("kms", _set_tags_kms_key),
|
|
413
|
+
("ecr", "repositories"): ("ecr", _set_tags_ecr_repository),
|
|
414
|
+
("stepfunctions", "state_machines"): ("stepfunctions", _set_tags_stepfunctions),
|
|
415
|
+
("kinesis", "streams"): ("kinesis", _set_tags_kinesis_stream),
|
|
416
|
+
("ssm", "parameters"): ("ssm", _set_tags_ssm_parameter),
|
|
417
|
+
("elasticloadbalancing", "load_balancers"): ("elbv2", _set_tags_elbv2_load_balancer),
|
|
418
|
+
("elasticache", "cache_clusters"): ("elasticache", _set_tags_elasticache_cluster),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# Delete registry: (service, type) -> (boto3_service, callable(client, resource_id))
|
|
422
|
+
DELETE_REGISTRY: dict[tuple[str, str], tuple[str, Any]] = {
|
|
423
|
+
("s3", "buckets"): ("s3", lambda c, rid: c.delete_bucket(Bucket=rid)),
|
|
424
|
+
("sqs", "queues"): ("sqs", lambda c, rid: c.delete_queue(QueueUrl=c.get_queue_url(QueueName=rid)["QueueUrl"])),
|
|
425
|
+
("lambda", "functions"): ("lambda", lambda c, rid: c.delete_function(FunctionName=rid)),
|
|
426
|
+
("dynamodb", "tables"): ("dynamodb", lambda c, rid: c.delete_table(TableName=rid)),
|
|
427
|
+
("secretsmanager", "secrets"): ("secretsmanager", lambda c, rid: c.delete_secret(SecretId=rid, ForceDeleteWithoutRecovery=True)),
|
|
428
|
+
("ec2", "instances"): ("ec2", lambda c, rid: c.terminate_instances(InstanceIds=[rid])),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# --- Routes ---
|
|
433
|
+
|
|
434
|
+
@router.get("/tags/supported")
|
|
435
|
+
def get_supported_tags() -> dict[str, Any]:
|
|
436
|
+
"""Return the list of (service, type) pairs that support tagging."""
|
|
437
|
+
supported = []
|
|
438
|
+
for (service, rtype) in TAG_GETTER_REGISTRY:
|
|
439
|
+
writable = (service, rtype) in TAG_SETTER_REGISTRY
|
|
440
|
+
supported.append({"service": service, "type": rtype, "writable": writable})
|
|
441
|
+
return {"supported": supported}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@router.get("/tags/{service}/{resource_type}/{resource_id:path}")
|
|
445
|
+
def get_resource_tags(service: str, resource_type: str, resource_id: str) -> dict[str, Any]:
|
|
446
|
+
"""Get tags for a specific resource."""
|
|
447
|
+
key = (service, resource_type)
|
|
448
|
+
if key not in TAG_GETTER_REGISTRY:
|
|
449
|
+
raise HTTPException(status_code=400, detail=f"Tagging not supported for {service}/{resource_type}")
|
|
450
|
+
|
|
451
|
+
boto3_service, getter_fn = TAG_GETTER_REGISTRY[key]
|
|
452
|
+
try:
|
|
453
|
+
client = get_client(boto3_service)
|
|
454
|
+
tags = getter_fn(client, resource_id)
|
|
455
|
+
return {"service": service, "type": resource_type, "id": resource_id, "tags": tags}
|
|
456
|
+
except Exception as e:
|
|
457
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@router.put("/tags/{service}/{resource_type}/{resource_id:path}")
|
|
461
|
+
def update_resource_tags(service: str, resource_type: str, resource_id: str, body: TagUpdateRequest) -> dict[str, Any]:
|
|
462
|
+
"""Set tags for a specific resource (full replace)."""
|
|
463
|
+
key = (service, resource_type)
|
|
464
|
+
if key not in TAG_SETTER_REGISTRY:
|
|
465
|
+
raise HTTPException(status_code=400, detail=f"Tag editing not supported for {service}/{resource_type}")
|
|
466
|
+
|
|
467
|
+
boto3_service, setter_fn = TAG_SETTER_REGISTRY[key]
|
|
468
|
+
try:
|
|
469
|
+
client = get_client(boto3_service)
|
|
470
|
+
setter_fn(client, resource_id, body.tags)
|
|
471
|
+
return {"success": True, "service": service, "type": resource_type, "id": resource_id, "tags": body.tags}
|
|
472
|
+
except Exception as e:
|
|
473
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@router.post("/bulk/tag")
|
|
477
|
+
def bulk_tag(body: BulkTagRequest) -> dict[str, Any]:
|
|
478
|
+
"""Bulk add or remove tags across multiple resources."""
|
|
479
|
+
if body.action not in ("add", "remove"):
|
|
480
|
+
raise HTTPException(status_code=400, detail="action must be 'add' or 'remove'")
|
|
481
|
+
|
|
482
|
+
if not body.resources:
|
|
483
|
+
raise HTTPException(status_code=400, detail="resources list is required")
|
|
484
|
+
|
|
485
|
+
if not body.tags:
|
|
486
|
+
raise HTTPException(status_code=400, detail="tags are required")
|
|
487
|
+
|
|
488
|
+
results: list[dict[str, Any]] = []
|
|
489
|
+
for resource in body.resources:
|
|
490
|
+
svc = resource.get("service", "")
|
|
491
|
+
rtype = resource.get("type", "")
|
|
492
|
+
rid = resource.get("id", "")
|
|
493
|
+
key = (svc, rtype)
|
|
494
|
+
|
|
495
|
+
if key not in TAG_GETTER_REGISTRY or key not in TAG_SETTER_REGISTRY:
|
|
496
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": False, "error": "Tagging not supported"})
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
boto3_svc_get, getter_fn = TAG_GETTER_REGISTRY[key]
|
|
500
|
+
boto3_svc_set, setter_fn = TAG_SETTER_REGISTRY[key]
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
client = get_client(boto3_svc_get)
|
|
504
|
+
existing = getter_fn(client, rid)
|
|
505
|
+
|
|
506
|
+
if body.action == "add":
|
|
507
|
+
merged = {**existing, **body.tags}
|
|
508
|
+
else:
|
|
509
|
+
merged = {k: v for k, v in existing.items() if k not in body.tags}
|
|
510
|
+
|
|
511
|
+
set_client = get_client(boto3_svc_set)
|
|
512
|
+
setter_fn(set_client, rid, merged)
|
|
513
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": True})
|
|
514
|
+
except Exception as e:
|
|
515
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": False, "error": str(e)})
|
|
516
|
+
|
|
517
|
+
succeeded = sum(1 for r in results if r["success"])
|
|
518
|
+
failed = sum(1 for r in results if not r["success"])
|
|
519
|
+
return {"results": results, "succeeded": succeeded, "failed": failed}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@router.post("/bulk/delete")
|
|
523
|
+
def bulk_delete(body: BulkDeleteRequest) -> dict[str, Any]:
|
|
524
|
+
"""Bulk delete multiple resources across services."""
|
|
525
|
+
if not body.resources:
|
|
526
|
+
raise HTTPException(status_code=400, detail="resources list is required")
|
|
527
|
+
|
|
528
|
+
results: list[dict[str, Any]] = []
|
|
529
|
+
for resource in body.resources:
|
|
530
|
+
svc = resource.get("service", "")
|
|
531
|
+
rtype = resource.get("type", "")
|
|
532
|
+
rid = resource.get("id", "")
|
|
533
|
+
key = (svc, rtype)
|
|
534
|
+
|
|
535
|
+
if key not in DELETE_REGISTRY:
|
|
536
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": False, "error": "Delete not supported"})
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
boto3_svc, delete_fn = DELETE_REGISTRY[key]
|
|
540
|
+
try:
|
|
541
|
+
client = get_client(boto3_svc)
|
|
542
|
+
delete_fn(client, rid)
|
|
543
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": True})
|
|
544
|
+
except Exception as e:
|
|
545
|
+
results.append({"service": svc, "type": rtype, "id": rid, "success": False, "error": str(e)})
|
|
546
|
+
|
|
547
|
+
succeeded = sum(1 for r in results if r["success"])
|
|
548
|
+
failed = sum(1 for r in results if not r["success"])
|
|
549
|
+
return {"results": results, "succeeded": succeeded, "failed": failed}
|