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
5 changes: 5 additions & 0 deletions .changeset/ecs-real-capacity-and-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-glance": patch
---

Report real ECS cluster capacity and status instead of placeholders. The CPU and Memory columns were both set to the registered container-instance count, so three columns showed the same unrelated number, and the Health column was hardcoded to OK regardless of the cluster. CPU and memory are now the share of registered capacity in use, read from the container instances backing the cluster, and Status is the lifecycle state ECS reports. Fargate clusters register no instances and have no cluster-level capacity pool, so they show a dash rather than a zero that would read as an idle cluster. Capacity is only looked up for clusters that actually have container instances, so a Fargate-only account makes no extra API calls.
80 changes: 77 additions & 3 deletions src/aws/ecs.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
use crate::{app::App, aws::tags, models::EcsClusterInfo};
use crate::{
app::App,
aws::tags,
models::{ecs::ClusterCapacity, EcsClusterInfo},
};
use aws_sdk_ecs::types::ClusterField;
use aws_sdk_ecs::Client as EcsClient;

/// Resource names ECS reports on a container instance.
const CPU_RESOURCE: &str = "CPU";
const MEMORY_RESOURCE: &str = "MEMORY";

/// Sum one named resource across a set of container-instance resource lists.
fn total(resources: &[aws_sdk_ecs::types::Resource], name: &str) -> i32 {
resources
.iter()
.filter(|resource| resource.name() == Some(name))
.map(|resource| resource.integer_value())
.sum()
}

/// Capacity registered by the instances backing a cluster.
///
/// Only called when the cluster reports container instances, so a Fargate-only
/// cluster costs no extra requests. Returns `None` if the lookup fails, leaving
/// the columns blank rather than reporting a capacity that was never read.
async fn fetch_cluster_capacity(ecs: &EcsClient, cluster_arn: &str) -> Option<ClusterCapacity> {
let mut capacity = ClusterCapacity {
registered_cpu_units: 0,
available_cpu_units: 0,
registered_memory_mib: 0,
available_memory_mib: 0,
};

let mut pages = ecs
.list_container_instances()
.cluster(cluster_arn)
.into_paginator()
.items()
.send();

let mut instance_arns = Vec::new();
while let Some(item) = pages.next().await {
instance_arns.push(item.ok()?);
}

// DescribeContainerInstances accepts at most 100 identifiers per call.
for chunk in instance_arns.chunks(100) {
let mut request = ecs.describe_container_instances().cluster(cluster_arn);
for arn in chunk {
request = request.container_instances(arn);
}

let response = request.send().await.ok()?;

for instance in response.container_instances() {
let registered = instance.registered_resources();
let remaining = instance.remaining_resources();

capacity.registered_cpu_units += total(registered, CPU_RESOURCE);
capacity.available_cpu_units += total(remaining, CPU_RESOURCE);
capacity.registered_memory_mib += total(registered, MEMORY_RESOURCE);
capacity.available_memory_mib += total(remaining, MEMORY_RESOURCE);
}
}

Some(capacity)
}

pub async fn fetch_ecs_clusters(app: &App) -> Vec<EcsClusterInfo> {
// TODO(#16): surface throttle/denied errors in the UI instead of degrading
Expand Down Expand Up @@ -33,6 +99,14 @@ pub async fn fetch_ecs_clusters(app: &App) -> Vec<EcsClusterInfo> {
};

for c in resp.clusters() {
// Fargate clusters register no instances and have no capacity pool,
// so this stays None and costs no extra requests.
let capacity = if c.registered_container_instances_count() > 0 {
fetch_cluster_capacity(&app.aws.ecs, c.cluster_arn().unwrap_or_default()).await
} else {
None
};

clusters.push(EcsClusterInfo {
tags: tags::from_pairs(c.tags().iter().map(|t| (t.key(), t.value()))),
arn: c.cluster_arn().unwrap_or("").into(),
Expand All @@ -41,8 +115,8 @@ pub async fn fetch_ecs_clusters(app: &App) -> Vec<EcsClusterInfo> {
pending_tasks: c.pending_tasks_count(),
active_services: c.active_services_count(),
registered_container_instances: c.registered_container_instances_count(),
cpu: c.registered_container_instances_count(), // placeholder
memory: c.registered_container_instances_count(), // placeholder
status: c.status().unwrap_or_default().to_string(),
capacity,
});
}
}
Expand Down
198 changes: 196 additions & 2 deletions src/models/ecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ use crate::{
models::tags::Tags,
};

/// Compute capacity registered with a cluster by the container instances
/// backing it.
///
/// Only EC2-backed clusters have one. Fargate provisions capacity per task with
/// no cluster-level pool, so there is no total to report and those clusters
/// carry `None` rather than a zero that would read as an empty cluster.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClusterCapacity {
pub registered_cpu_units: i32,
pub available_cpu_units: i32,
pub registered_memory_mib: i32,
pub available_memory_mib: i32,
}

impl ClusterCapacity {
/// Share of registered capacity currently claimed by tasks, as a percent.
///
/// `None` when nothing is registered, which would otherwise divide by zero.
fn used_percent(registered: i32, available: i32) -> Option<u32> {
if registered <= 0 {
return None;
}

let used = registered.saturating_sub(available).max(0);

Some((used as i64 * 100 / registered as i64) as u32)
}

pub fn cpu_used_percent(&self) -> Option<u32> {
Self::used_percent(self.registered_cpu_units, self.available_cpu_units)
}

pub fn memory_used_percent(&self) -> Option<u32> {
Self::used_percent(self.registered_memory_mib, self.available_memory_mib)
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EcsClusterInfo {
pub arn: String,
Expand All @@ -15,11 +52,45 @@ pub struct EcsClusterInfo {
pub pending_tasks: i32,
pub active_services: i32,
pub registered_container_instances: i32,
pub cpu: i32,
pub memory: i32,
/// Cluster lifecycle status as ECS reports it: ACTIVE, PROVISIONING,
/// DEPROVISIONING, FAILED, or INACTIVE.
#[serde(default)]
pub status: String,
/// `None` for Fargate-only clusters, which have no capacity pool.
#[serde(default)]
pub capacity: Option<ClusterCapacity>,
pub tags: Tags,
}

impl EcsClusterInfo {
/// How a capacity share renders in a column, or `-` when the cluster has no
/// capacity pool to measure against.
fn capacity_label(percent: Option<u32>) -> String {
percent.map_or_else(|| "-".to_string(), |value| format!("{value}%"))
}

pub fn cpu_label(&self) -> String {
Self::capacity_label(self.capacity.and_then(|c| c.cpu_used_percent()))
}

pub fn memory_label(&self) -> String {
Self::capacity_label(self.capacity.and_then(|c| c.memory_used_percent()))
}

pub fn status_label(&self) -> String {
if self.status.is_empty() {
"-".to_string()
} else {
self.status.clone()
}
}

/// Whether the cluster is in its normal serving state.
pub fn is_active(&self) -> bool {
self.status.eq_ignore_ascii_case("ACTIVE")
}
}

#[async_trait]
impl DescribableResource for EcsClusterInfo {
fn resource_name(&self) -> String {
Expand Down Expand Up @@ -53,3 +124,126 @@ impl DescribableResource for EcsClusterInfo {
))
}
}

#[cfg(test)]
mod tests {
use super::*;

fn cluster(capacity: Option<ClusterCapacity>) -> EcsClusterInfo {
EcsClusterInfo {
arn: "arn:aws:ecs:us-east-1:1:cluster/core".into(),
name: "core".into(),
running_tasks: 4,
pending_tasks: 0,
active_services: 4,
registered_container_instances: 0,
status: "ACTIVE".into(),
capacity,
tags: Tags::empty(),
}
}

fn capacity(reg_cpu: i32, avail_cpu: i32, reg_mem: i32, avail_mem: i32) -> ClusterCapacity {
ClusterCapacity {
registered_cpu_units: reg_cpu,
available_cpu_units: avail_cpu,
registered_memory_mib: reg_mem,
available_memory_mib: avail_mem,
}
}

#[test]
fn utilization_is_the_share_of_registered_capacity_in_use() {
let half_cpu = capacity(4096, 2048, 8192, 6144);

assert_eq!(half_cpu.cpu_used_percent(), Some(50));
assert_eq!(half_cpu.memory_used_percent(), Some(25));
}

#[test]
fn a_fully_free_cluster_reads_as_zero_not_absent() {
let idle = capacity(4096, 4096, 8192, 8192);

assert_eq!(idle.cpu_used_percent(), Some(0));
assert_eq!(cluster(Some(idle)).cpu_label(), "0%");
}

#[test]
fn a_fully_claimed_cluster_reads_as_one_hundred() {
let full = capacity(4096, 0, 8192, 0);

assert_eq!(full.cpu_used_percent(), Some(100));
assert_eq!(full.memory_used_percent(), Some(100));
}

/// Fargate registers no instances, so there is no pool to measure against.
/// Reporting 0% would read as an idle cluster rather than an inapplicable
/// measurement, which is what the placeholder used to do.
#[test]
fn a_fargate_cluster_reports_no_utilization() {
let fargate = cluster(None);

assert_eq!(fargate.cpu_label(), "-");
assert_eq!(fargate.memory_label(), "-");
}

#[test]
fn nothing_registered_cannot_divide_by_zero() {
let empty = capacity(0, 0, 0, 0);

assert_eq!(empty.cpu_used_percent(), None);
assert_eq!(empty.memory_used_percent(), None);
assert_eq!(cluster(Some(empty)).cpu_label(), "-");
}

/// ECS can report more available than registered while an instance drains.
/// That is not negative usage.
#[test]
fn more_available_than_registered_is_clamped_to_zero_used() {
let draining = capacity(4096, 5000, 8192, 9000);

assert_eq!(draining.cpu_used_percent(), Some(0));
assert_eq!(draining.memory_used_percent(), Some(0));
}

/// Clusters cached before this field existed still load.
#[test]
fn a_cached_cluster_without_capacity_still_deserializes() {
let older = r#"{
"arn": "arn:aws:ecs:us-east-1:1:cluster/core",
"name": "core",
"running_tasks": 4,
"pending_tasks": 0,
"active_services": 4,
"registered_container_instances": 0,
"tags": "Unavailable"
}"#;

let restored: EcsClusterInfo = serde_json::from_str(older).expect("older shape loads");

assert_eq!(restored.capacity, None);
assert_eq!(restored.cpu_label(), "-");
// Status was not stored either, so it reads as unknown rather than OK.
assert_eq!(restored.status_label(), "-");
}

#[test]
fn a_cluster_reports_the_status_ecs_gave_it() {
let mut provisioning = cluster(None);
provisioning.status = "PROVISIONING".into();

assert_eq!(provisioning.status_label(), "PROVISIONING");
assert!(!provisioning.is_active());
assert!(cluster(None).is_active());
}

/// Never invent a status. An empty one means it was not reported.
#[test]
fn an_unreported_status_is_not_claimed_to_be_healthy() {
let mut unknown = cluster(None);
unknown.status = String::new();

assert_eq!(unknown.status_label(), "-");
assert!(!unknown.is_active());
}
}
20 changes: 12 additions & 8 deletions src/ui/views/ecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ pub fn render_ecs_clusters(frame: &mut Frame, area: Rect, app: &mut App) {
"Services",
"Tasks (R/P)",
"EC2s",
"CPU",
"Memory",
"Health",
"CPU Used",
"Mem Used",
"Status",
],
widths: &[
Constraint::Percentage(30),
Expand All @@ -57,12 +57,16 @@ pub fn render_ecs_clusters(frame: &mut Frame, area: Rect, app: &mut App) {
c.active_services.to_string(),
format!("{} / {}", c.running_tasks, c.pending_tasks),
c.registered_container_instances.to_string(),
c.cpu.to_string(),
c.memory.to_string(),
// TODO(#43): cluster health is a placeholder, not yet computed.
"OK".to_string(),
c.cpu_label(),
c.memory_label(),
c.status_label(),
],
style: Style::default().fg(theme.text),
style: if c.is_active() {
Style::default().fg(theme.text)
} else {
// Anything other than ACTIVE is worth noticing.
Style::default().fg(theme.primary)
},
}
},
);
Expand Down
Loading