Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions backend/app/api/routes/ecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
ECSClusterSummary,
ECSContainerDetail,
ECSContainerResponse,
ECSSummaryResponse,
ListResponse,
ManagedBy,
MetaInfo,
)

Expand Down Expand Up @@ -88,6 +90,12 @@ async def list_ecs_clusters(
cluster_containers[0].region.name if cluster_containers[0].region else None
)

# Determine cluster-level managed_by
if any_tf:
cluster_managed_by = ManagedBy.TERRAFORM
else:
cluster_managed_by = ManagedBy.UNMANAGED

container_responses = [_container_to_response(c) for c in cluster_containers]

cluster_summaries.append(
Expand All @@ -98,6 +106,7 @@ async def list_ecs_clusters(
stopped_tasks=stopped,
pending_tasks=pending,
tf_managed=any_tf,
managed_by=cluster_managed_by,
region_name=region_name,
containers=container_responses,
)
Expand Down Expand Up @@ -219,6 +228,46 @@ async def get_ecs_container(
return _container_to_detail(container)


@router.get("/ecs/summary", response_model=ECSSummaryResponse)
async def get_ecs_summary(
db: AsyncSession = Depends(get_db),
):
"""
Get summary counts for ECS resources.

Returns cluster count, running/stopped/pending task counts.
"""
query = (
select(ECSContainer)
.where(ECSContainer.is_deleted == False)
)
result = await db.execute(query)
containers = result.scalars().all()

cluster_names = set()
running = 0
stopped = 0
pending = 0

for c in containers:
cluster_names.add(c.cluster_name)
if c.status == "RUNNING":
running += 1
elif c.status == "STOPPED":
stopped += 1
elif c.status in ("PENDING", "PROVISIONING", "ACTIVATING"):
pending += 1

return ECSSummaryResponse(
clusters=len(cluster_names),
services=0,
running_tasks=running,
stopped_tasks=stopped,
pending_tasks=pending,
total_tasks=len(containers),
)


def _get_statuses_for_display(status: DisplayStatus) -> list[str]:
"""Map display status to ECS task statuses."""
mapping = {
Expand All @@ -238,6 +287,18 @@ def _get_statuses_for_display(status: DisplayStatus) -> list[str]:
return mapping.get(status, [])


def _resolve_managed_by(container: ECSContainer) -> ManagedBy:
"""Resolve the management source for a container."""
if container.tf_managed:
return ManagedBy.TERRAFORM
managed = getattr(container, "managed_by", "unmanaged")
if managed == "github_actions":
return ManagedBy.GITHUB_ACTIONS
if managed == "terraform":
return ManagedBy.TERRAFORM
return ManagedBy.UNMANAGED


def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
"""Convert ECSContainer model to response schema."""
tags = None
Expand All @@ -260,6 +321,7 @@ def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
task_definition_arn=container.task_definition_arn,
desired_status=container.desired_status,
image=container.image,
image_tag=getattr(container, "image_tag", None),
container_port=container.container_port,
private_ip=container.private_ip,
subnet_id=container.subnet_id,
Expand All @@ -270,6 +332,7 @@ def _container_to_response(container: ECSContainer) -> ECSContainerResponse:
tf_managed=container.tf_managed,
tf_state_source=container.tf_state_source,
tf_resource_address=container.tf_resource_address,
managed_by=_resolve_managed_by(container),
region_name=container.region.name if container.region else None,
is_deleted=container.is_deleted,
deleted_at=container.deleted_at,
Expand Down Expand Up @@ -299,6 +362,7 @@ def _container_to_detail(container: ECSContainer) -> ECSContainerDetail:
task_definition_arn=container.task_definition_arn,
desired_status=container.desired_status,
image=container.image,
image_tag=getattr(container, "image_tag", None),
container_port=container.container_port,
private_ip=container.private_ip,
subnet_id=container.subnet_id,
Expand All @@ -309,6 +373,7 @@ def _container_to_detail(container: ECSContainer) -> ECSContainerDetail:
tf_managed=container.tf_managed,
tf_state_source=container.tf_state_source,
tf_resource_address=container.tf_resource_address,
managed_by=_resolve_managed_by(container),
region_name=container.region.name if container.region else None,
is_deleted=container.is_deleted,
deleted_at=container.deleted_at,
Expand Down
4 changes: 4 additions & 0 deletions backend/app/api/routes/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,13 +807,15 @@ async def _sync_ecs_containers(
existing.cpu = container_data.get("cpu", 0)
existing.memory = container_data.get("memory", 0)
existing.image = container_data.get("image")
existing.image_tag = container_data.get("image_tag")
existing.container_port = container_data.get("container_port")
existing.private_ip = container_data.get("private_ip")
existing.subnet_id = container_data.get("subnet_id")
existing.vpc_id = container_data.get("vpc_id")
existing.availability_zone = container_data.get("availability_zone")
existing.started_at = container_data.get("started_at")
existing.tags = json.dumps(container_data.get("tags", {}))
existing.managed_by = container_data.get("managed_by", "unmanaged")
existing.is_deleted = False
existing.deleted_at = None
else:
Expand All @@ -829,13 +831,15 @@ async def _sync_ecs_containers(
cpu=container_data.get("cpu", 0),
memory=container_data.get("memory", 0),
image=container_data.get("image"),
image_tag=container_data.get("image_tag"),
container_port=container_data.get("container_port"),
private_ip=container_data.get("private_ip"),
subnet_id=container_data.get("subnet_id"),
vpc_id=container_data.get("vpc_id"),
availability_zone=container_data.get("availability_zone"),
started_at=container_data.get("started_at"),
tags=json.dumps(container_data.get("tags", {})),
managed_by=container_data.get("managed_by", "unmanaged"),
is_deleted=False,
)
db.add(new_container)
Expand Down
15 changes: 12 additions & 3 deletions backend/app/api/routes/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,11 @@ async def get_topology(
)
)

# Get ECS containers in this subnet
# Get ECS containers in this subnet (include both TF-managed
# and CI/CD-deployed containers for full visibility)
ecs_result = await db.execute(
select(ECSContainer).where(
ECSContainer.subnet_id == subnet.subnet_id,
ECSContainer.tf_managed == True,
ECSContainer.is_deleted == False,
)
)
Expand All @@ -293,6 +293,13 @@ async def get_topology(
topology_ecs = []
for ecs_container in ecs_containers:
total_ecs_containers += 1
# Resolve managed_by: TF flag takes precedence,
# then fall back to the stored managed_by value
managed_by_value = "unmanaged"
if ecs_container.tf_managed:
managed_by_value = "terraform"
elif getattr(ecs_container, "managed_by", "") == "github_actions":
managed_by_value = "github_actions"
topology_ecs.append(
TopologyECSContainer(
id=ecs_container.task_id,
Expand All @@ -304,10 +311,12 @@ async def get_topology(
cpu=ecs_container.cpu,
memory=ecs_container.memory,
image=ecs_container.image,
image_tag=getattr(ecs_container, "image_tag", None),
container_port=ecs_container.container_port,
private_ip=ecs_container.private_ip,
tf_managed=True,
tf_managed=ecs_container.tf_managed,
tf_resource_address=ecs_container.tf_resource_address,
managed_by=managed_by_value,
)
)

Expand Down
44 changes: 44 additions & 0 deletions backend/app/collectors/ecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
ECS Container collector.

Collects ECS task and container data from AWS using the boto3 SDK.
Detects management source (Terraform, GitHub Actions, unmanaged) based on
container image tags and resource tags.
"""

import logging
import re
from typing import Any, Dict, List, Optional

from botocore.exceptions import ClientError
Expand All @@ -13,6 +16,13 @@

logger = logging.getLogger(__name__)

# Patterns that indicate CI/CD deployment (GitHub Actions)
GITHUB_ACTIONS_PATTERNS = [
re.compile(r"^[a-f0-9]{40}$"), # Full git SHA
re.compile(r"^[a-f0-9]{7,8}$"), # Short git SHA
re.compile(r"^v\d+\.\d+\.\d+"), # Semver (v1.2.3)
]


class ECSCollector(BaseCollector):
"""Collector for ECS containers (tasks running in clusters)."""
Expand Down Expand Up @@ -102,11 +112,15 @@ def _parse_task(
container_name = None
container_port = None
image = None
image_tag = None

if containers:
first_container = containers[0]
container_name = first_container.get("name")
image = first_container.get("image")
# Extract image tag
if image and ":" in image:
image_tag = image.split(":")[-1]
network_bindings = first_container.get("networkBindings", [])
if network_bindings:
container_port = network_bindings[0].get("containerPort")
Expand All @@ -133,6 +147,9 @@ def _parse_task(
cpu = task.get("cpu")
memory = task.get("memory")

# Detect management source from image tag and tags
managed_by = self._detect_managed_by(image_tag, tags_dict)

return {
"task_id": task_id,
"task_arn": task_arn,
Expand All @@ -146,12 +163,14 @@ def _parse_task(
"memory": int(memory) if memory else 0,
"container_port": container_port,
"image": image,
"image_tag": image_tag,
"subnet_id": subnet_id,
"private_ip": private_ip,
"vpc_id": None, # Resolved from subnet if needed
"availability_zone": task.get("availabilityZone"),
"started_at": task.get("startedAt"),
"tags": tags_dict,
"managed_by": managed_by,
"region": self.region,
}

Expand All @@ -162,6 +181,31 @@ def _parse_task(
logger.warning(f"Error parsing ECS task: {e}")
return None

@staticmethod
def _detect_managed_by(
image_tag: Optional[str], tags: Dict[str, str]
) -> str:
"""
Detect who manages this task based on image tag patterns and resource tags.

Returns:
"github_actions" if deployed by CI/CD, "unmanaged" otherwise.
Terraform management is resolved later during aggregation.
"""
# Check image tag against CI/CD patterns
if image_tag:
for pattern in GITHUB_ACTIONS_PATTERNS:
if pattern.match(image_tag):
return "github_actions"

# Check resource tags for deployment markers
if tags.get("deployed-by") == "github-actions":
return "github_actions"
if tags.get("managed-by") == "github-actions":
return "github_actions"

return "unmanaged"

async def collect_task(
self, cluster_name: str, task_id: str
) -> Optional[Dict[str, Any]]:
Expand Down
6 changes: 6 additions & 0 deletions backend/app/models/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ class ECSContainer(Base):

# Container details
image: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
image_tag: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
container_port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

# Network
Expand All @@ -621,6 +622,11 @@ class ECSContainer(Base):
String(500), nullable=True
)

# Management source tracking (terraform, github_actions, unmanaged)
managed_by: Mapped[str] = mapped_column(
String(30), nullable=False, default="unmanaged"
)

# Deletion tracking
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
Expand Down
24 changes: 24 additions & 0 deletions backend/app/schemas/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ class DisplayStatus(str, Enum):
UNKNOWN = "unknown"


class ManagedBy(str, Enum):
"""Management source for a resource."""

TERRAFORM = "terraform"
GITHUB_ACTIONS = "github_actions"
UNMANAGED = "unmanaged"


class EC2State(str, Enum):
"""EC2 instance states."""

Expand Down Expand Up @@ -377,6 +385,7 @@ class ECSContainerResponse(ECSContainerBase):
task_definition_arn: Optional[str] = None
desired_status: Optional[str] = None
image: Optional[str] = None
image_tag: Optional[str] = None
container_port: Optional[int] = None
private_ip: Optional[str] = None
subnet_id: Optional[str] = None
Expand All @@ -387,6 +396,7 @@ class ECSContainerResponse(ECSContainerBase):
tf_managed: bool = False
tf_state_source: Optional[str] = None
tf_resource_address: Optional[str] = None
managed_by: ManagedBy = ManagedBy.UNMANAGED
region_name: Optional[str] = Field(None, description="AWS region name")
is_deleted: bool = False
deleted_at: Optional[datetime] = None
Expand All @@ -408,10 +418,22 @@ class ECSClusterSummary(BaseSchema):
stopped_tasks: int = 0
pending_tasks: int = 0
tf_managed: bool = False
managed_by: ManagedBy = ManagedBy.UNMANAGED
region_name: Optional[str] = None
containers: List[ECSContainerResponse] = []


class ECSSummaryResponse(BaseSchema):
"""Summary counts for ECS resources."""

clusters: int = 0
services: int = 0
running_tasks: int = 0
stopped_tasks: int = 0
pending_tasks: int = 0
total_tasks: int = 0


# =============================================================================
# Terraform Schemas
# =============================================================================
Expand Down Expand Up @@ -619,10 +641,12 @@ class TopologyECSContainer(BaseSchema):
cpu: int = 0
memory: int = 0
image: Optional[str] = None
image_tag: Optional[str] = None
container_port: Optional[int] = None
private_ip: Optional[str] = None
tf_managed: bool = True
tf_resource_address: Optional[str] = None
managed_by: str = "unmanaged"


class TopologySubnet(BaseSchema):
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock("@/pages", () => ({
),
EC2ListPage: () => <div data-testid="ec2-page">EC2 Content</div>,
RDSListPage: () => <div data-testid="rds-page">RDS Content</div>,
ECSListPage: () => <div data-testid="ecs-page">ECS Content</div>,
VPCPage: () => <div data-testid="vpc-page">VPC Content</div>,
TerraformPage: () => (
<div data-testid="terraform-page">Terraform Content</div>
Expand Down
Loading
Loading