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.
Files changed (92) hide show
  1. {stackport-0.1.9/stackport.egg-info → stackport-0.2.0}/PKG-INFO +1 -1
  2. {stackport-0.1.9 → stackport-0.2.0}/backend/cache.py +7 -2
  3. {stackport-0.1.9 → stackport-0.2.0}/backend/config.py +5 -0
  4. {stackport-0.1.9 → stackport-0.2.0}/backend/main.py +2 -1
  5. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/stats.py +8 -3
  6. stackport-0.2.0/backend/routes/tags.py +549 -0
  7. {stackport-0.1.9 → stackport-0.2.0}/pyproject.toml +1 -1
  8. {stackport-0.1.9 → stackport-0.2.0/stackport.egg-info}/PKG-INFO +1 -1
  9. {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/SOURCES.txt +6 -4
  10. {stackport-0.1.9 → stackport-0.2.0}/tests/test_config.py +30 -0
  11. stackport-0.2.0/tests/test_tags_routes.py +826 -0
  12. stackport-0.2.0/ui/dist/assets/index-BauDt01Y.css +1 -0
  13. stackport-0.2.0/ui/dist/assets/index-CMbP0j4Q.js +563 -0
  14. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/index.html +2 -2
  15. stackport-0.1.9/ui/dist/assets/index-CWKwl2Ak.css +0 -1
  16. stackport-0.1.9/ui/dist/assets/index-Det-eQBZ.js +0 -552
  17. {stackport-0.1.9 → stackport-0.2.0}/LICENSE +0 -0
  18. {stackport-0.1.9 → stackport-0.2.0}/MANIFEST.in +0 -0
  19. {stackport-0.1.9 → stackport-0.2.0}/README.md +0 -0
  20. {stackport-0.1.9 → stackport-0.2.0}/backend/__init__.py +0 -0
  21. {stackport-0.1.9 → stackport-0.2.0}/backend/aws_client.py +0 -0
  22. {stackport-0.1.9 → stackport-0.2.0}/backend/cli.py +0 -0
  23. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/__init__.py +0 -0
  24. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/common.py +0 -0
  25. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/dynamodb.py +0 -0
  26. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/ec2.py +0 -0
  27. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/endpoints.py +0 -0
  28. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/iam.py +0 -0
  29. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/lambda_svc.py +0 -0
  30. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/logs.py +0 -0
  31. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/resources.py +0 -0
  32. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/s3.py +0 -0
  33. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/secretsmanager.py +0 -0
  34. {stackport-0.1.9 → stackport-0.2.0}/backend/routes/sqs.py +0 -0
  35. {stackport-0.1.9 → stackport-0.2.0}/backend/websocket.py +0 -0
  36. {stackport-0.1.9 → stackport-0.2.0}/setup.cfg +0 -0
  37. {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/dependency_links.txt +0 -0
  38. {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/entry_points.txt +0 -0
  39. {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/requires.txt +0 -0
  40. {stackport-0.1.9 → stackport-0.2.0}/stackport.egg-info/top_level.txt +0 -0
  41. {stackport-0.1.9 → stackport-0.2.0}/tests/test_cache.py +0 -0
  42. {stackport-0.1.9 → stackport-0.2.0}/tests/test_cli.py +0 -0
  43. {stackport-0.1.9 → stackport-0.2.0}/tests/test_client.py +0 -0
  44. {stackport-0.1.9 → stackport-0.2.0}/tests/test_dynamodb_routes.py +0 -0
  45. {stackport-0.1.9 → stackport-0.2.0}/tests/test_ec2_routes.py +0 -0
  46. {stackport-0.1.9 → stackport-0.2.0}/tests/test_endpoints.py +0 -0
  47. {stackport-0.1.9 → stackport-0.2.0}/tests/test_iam_routes.py +0 -0
  48. {stackport-0.1.9 → stackport-0.2.0}/tests/test_lambda_routes.py +0 -0
  49. {stackport-0.1.9 → stackport-0.2.0}/tests/test_logs_routes.py +0 -0
  50. {stackport-0.1.9 → stackport-0.2.0}/tests/test_registries.py +0 -0
  51. {stackport-0.1.9 → stackport-0.2.0}/tests/test_routes.py +0 -0
  52. {stackport-0.1.9 → stackport-0.2.0}/tests/test_s3_routes.py +0 -0
  53. {stackport-0.1.9 → stackport-0.2.0}/tests/test_s3_upload_limit_env.py +0 -0
  54. {stackport-0.1.9 → stackport-0.2.0}/tests/test_secretsmanager_routes.py +0 -0
  55. {stackport-0.1.9 → stackport-0.2.0}/tests/test_sqs_routes.py +0 -0
  56. {stackport-0.1.9 → stackport-0.2.0}/tests/test_websocket.py +0 -0
  57. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/acm.svg +0 -0
  58. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/apigateway.svg +0 -0
  59. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/appsync.svg +0 -0
  60. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/athena.svg +0 -0
  61. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cloudformation.svg +0 -0
  62. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cloudfront.svg +0 -0
  63. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/cognito-idp.svg +0 -0
  64. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/dynamodb.svg +0 -0
  65. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ec2.svg +0 -0
  66. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ecr.svg +0 -0
  67. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ecs.svg +0 -0
  68. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticache.svg +0 -0
  69. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
  70. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
  71. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
  72. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/events.svg +0 -0
  73. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/firehose.svg +0 -0
  74. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/glue.svg +0 -0
  75. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/iam.svg +0 -0
  76. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/kinesis.svg +0 -0
  77. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/kms.svg +0 -0
  78. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/lambda.svg +0 -0
  79. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/logs.svg +0 -0
  80. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/monitoring.svg +0 -0
  81. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/rds.svg +0 -0
  82. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/route53.svg +0 -0
  83. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/s3.svg +0 -0
  84. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/secretsmanager.svg +0 -0
  85. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ses.svg +0 -0
  86. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/sns.svg +0 -0
  87. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/sqs.svg +0 -0
  88. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/ssm.svg +0 -0
  89. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/stepfunctions.svg +0 -0
  90. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/aws-icons/wafv2.svg +0 -0
  91. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/favicon.png +0 -0
  92. {stackport-0.1.9 → stackport-0.2.0}/ui/dist/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.9
3
+ Version: 0.2.0
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -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 = 5):
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 AWS_ENDPOINT_URL, AWS_REGION, STACKPORT_SERVICES
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), 10)) as executor:
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, ttl=5)
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}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackport"
3
- version = "0.1.9"
3
+ version = "0.2.0"
4
4
  description = "Universal AWS resource browser for local emulators"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.9
3
+ Version: 0.2.0
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT