Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
03ab91b
Extract ECS monitoring data collection to ServiceViewCollector
jutyler1 Dec 10, 2025
edd8124
Address CR feedback
jutyler1 Dec 11, 2025
006beab
Creating stream display to be used for text only monitoring
jutyler1 Dec 12, 2025
34898c1
Merge pull request #9907 from jutyler1/ecs-pr-v4-01
aemous Dec 12, 2025
b281b0b
Addressing PR comments
jutyler1 Dec 15, 2025
da6f3ea
Migrate ManagedResource(Group) tests to pytest
jutyler1 Dec 15, 2025
4a75702
Extract mode specific display into DisplayStrategy
jutyler1 Dec 15, 2025
7184988
Merge pull request #9915 from jutyler1/ecs-pr-v4-02
aemous Dec 15, 2025
0788841
Address PR comments
jutyler1 Dec 16, 2025
99e401d
Address PR comments
jutyler1 Dec 16, 2025
298b071
Address PR comments
jutyler1 Dec 16, 2025
ae43a41
Merge pull request #9923 from jutyler1/ecs-pr-v5-03
aemous Dec 16, 2025
532d26c
Introduce text only mode
jutyler1 Dec 15, 2025
cce5450
Address PR comments
jutyler1 Dec 17, 2025
7399df1
Address PR comments
jutyler1 Dec 17, 2025
1778b24
Address PR comments
jutyler1 Dec 17, 2025
802aa0b
Fix Windows unit tests
jutyler1 Dec 17, 2025
7176771
Merge pull request #9927 from jutyler1/ecs-pr-v5-04
aemous Dec 17, 2025
c5c7cb8
Add monitor-mode to monitor mutating commands
jutyler1 Dec 10, 2025
63badb5
Address PR comments
jutyler1 Dec 17, 2025
87fcd83
Address PR comments
jutyler1 Dec 18, 2025
134d926
Merge pull request #9931 from jutyler1/ecs-pr-v5-05
aemous Dec 18, 2025
5257e26
Remove Polling for updates status message
jutyler1 Dec 18, 2025
c1faea7
Merge remote-tracking branch 'remote/ecs-express-gateway-text-only' i…
jutyler1 Dec 19, 2025
cb5c9fc
Use UTC timezone with Z suffix for all timestamps
jutyler1 Dec 19, 2025
a7b1079
Merge pull request #9939 from jutyler1/ecs-pr-v5-06
aemous Dec 19, 2025
4eae13c
Merge remote-tracking branch 'remote/ecs-express-gateway-text-only' i…
jutyler1 Dec 19, 2025
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 .changes/next-release/enhancement-ecs-81617.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "``ecs``",
"description": "Introduces a text-only mode to the existing ECS Express Mode service commands. Text-only mode can be enabled via using the ``--mode TEXT-ONLY`` flag with the ``ecs monitor-express-gateway-service`` command, or via using the ``--monitor-mode TEXT-ONLY`` and ``--monitor-resources`` flags with the ``ecs create-express-gateway-service``, ``ecs update-express-gateway-service``, or ``ecs delete-express-gateway-service`` commands."
}
2 changes: 1 addition & 1 deletion awscli/customizations/ecs/expressgateway/color_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ColorUtils:

def __init__(self):
# Initialize colorama
init(autoreset=True, strip=False)
init(autoreset=False, strip=False)

def make_green(self, text, use_color=True):
if not use_color:
Expand Down
238 changes: 238 additions & 0 deletions awscli/customizations/ecs/expressgateway/display_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

"""Display strategy implementations for ECS Express Gateway Service monitoring."""

import asyncio
import time

from botocore.exceptions import ClientError
from colorama import Style

from awscli.customizations.ecs.exceptions import MonitoringError
from awscli.customizations.ecs.expressgateway.stream_display import (
StreamDisplay,
)
from awscli.customizations.utils import uni_print


class DisplayStrategy:
"""Base class for display strategies.

Each strategy controls its own execution model, timing, and output format.
"""

def execute_monitoring(self, collector, start_time, timeout_minutes):
"""Execute the monitoring loop.

Args:
collector: ServiceViewCollector instance for data fetching
start_time: Start timestamp for timeout calculation
timeout_minutes: Maximum monitoring duration in minutes
"""
raise NotImplementedError


class InteractiveDisplayStrategy(DisplayStrategy):
"""Interactive display strategy with async spinner and keyboard navigation.

Uses dual async tasks:
- Data task: Polls ECS APIs every 5 seconds
- Spinner task: Updates display every 100ms with rotating spinner
"""

def __init__(self, display, use_color):
"""Initialize the interactive display strategy.

Args:
display: Display instance from prompt_toolkit_display module
providing the interactive terminal interface
use_color: Whether to use colored output
"""
self.display = display
self.use_color = use_color

def execute_monitoring(self, collector, start_time, timeout_minutes):
"""Execute async monitoring with spinner and keyboard controls."""
try:
final_output, timed_out = asyncio.run(
self._execute_async(collector, start_time, timeout_minutes)
)
if timed_out:
uni_print(final_output + "\nMonitoring timed out!\n")
else:
uni_print(final_output + "\nMonitoring Complete!\n")
finally:
uni_print(Style.RESET_ALL)

async def _execute_async(self, collector, start_time, timeout_minutes):
"""Async execution with dual tasks for data and spinner."""
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
spinner_index = 0
current_output = "Waiting for initial data"
timed_out = False

async def update_data():
nonlocal current_output, timed_out
while True:
current_time = time.time()
if current_time - start_time > timeout_minutes * 60:
timed_out = True
# Only exit if app is running to avoid "Application is not running" error
if self.display.app.is_running:
self.display.app.exit()
break

try:
loop = asyncio.get_event_loop()
new_output = await loop.run_in_executor(
None, collector.get_current_view, "{SPINNER}"
)
current_output = new_output
except ClientError as e:
if (
e.response.get('Error', {}).get('Code')
== 'InvalidParameterException'
):
error_message = e.response.get('Error', {}).get(
'Message', ''
)
if (
"Cannot call DescribeServiceRevisions for a service that is INACTIVE"
in error_message
):
current_output = "Service is inactive"
else:
raise
else:
raise

await asyncio.sleep(5.0)

async def update_spinner():
nonlocal spinner_index
while True:
spinner_char = spinner_chars[spinner_index]
display_output = current_output.replace(
"{SPINNER}", spinner_char
)
status_text = f"Getting updates... {spinner_char} | up/down to scroll, q to quit"
self.display.display(display_output, status_text)
spinner_index = (spinner_index + 1) % len(spinner_chars)
await asyncio.sleep(0.1)

data_task = asyncio.create_task(update_data())
spinner_task = asyncio.create_task(update_spinner())
display_task = None

try:
display_task = asyncio.create_task(self.display.run())

done, pending = await asyncio.wait(
[display_task, data_task], return_when=asyncio.FIRST_COMPLETED
)

if data_task in done:
# Retrieve and re-raise any exception from the task.
# asyncio.wait() doesn't retrieve exceptions itself.
exc = data_task.exception()
if exc:
raise exc

# Cancel pending tasks
for task in pending:
task.cancel()
# Await cancelled task to ensure proper cleanup and prevent
# warnings about unawaited tasks
try:
await task
except asyncio.CancelledError:
pass

finally:
# Ensure display app is properly shut down
# Only exit if app is running to avoid "Application is not running" error
if self.display.app.is_running:
self.display.app.exit()
spinner_task.cancel()
if display_task is not None and not display_task.done():
display_task.cancel()
# Await cancelled task to ensure proper cleanup and prevent
# warnings about unawaited tasks
try:
await display_task
except asyncio.CancelledError:
pass

return current_output.replace("{SPINNER}", ""), timed_out


class TextOnlyDisplayStrategy(DisplayStrategy):
"""Text-only display strategy with diff detection and timestamped output.

Uses synchronous polling loop with change detection to output only
individual resource changes with timestamps.
"""

def __init__(self, use_color):
self.stream_display = StreamDisplay(use_color)

def execute_monitoring(self, collector, start_time, timeout_minutes):
"""Execute synchronous monitoring with text output."""
self.stream_display.show_startup_message()

try:
while True:
current_time = time.time()
if current_time - start_time > timeout_minutes * 60:
self.stream_display.show_timeout_message()
break

try:
collector.get_current_view("")

# Extract cached result for diff detection
managed_resources, info = collector.cached_monitor_result

self.stream_display.show_monitoring_data(
managed_resources, info
)

except ClientError as e:
if (
e.response.get('Error', {}).get('Code')
== 'InvalidParameterException'
):
error_message = e.response.get('Error', {}).get(
'Message', ''
)
if (
"Cannot call DescribeServiceRevisions for a service that is INACTIVE"
in error_message
):
self.stream_display.show_service_inactive_message()
break
else:
raise
else:
raise

time.sleep(5.0)

except KeyboardInterrupt:
self.stream_display.show_user_stop_message()
except MonitoringError as e:
self.stream_display.show_error_message(e)
finally:
self.stream_display.show_completion_message()
uni_print(Style.RESET_ALL)
80 changes: 69 additions & 11 deletions awscli/customizations/ecs/expressgateway/managedresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# language governing permissions and limitations under the License.

import sys
from datetime import datetime
from datetime import datetime, timezone

import dateutil.parser

Expand Down Expand Up @@ -113,6 +113,58 @@ def get_status_string(self, spinner_char, depth=0, use_color=True):
lines.append("")
return '\n'.join(lines)

def get_stream_string(self, timestamp, use_color=True):
"""Returns the resource information formatted for stream/text-only display.

Args:
timestamp (str): Timestamp string to prefix the output
use_color (bool): Whether to use ANSI color codes (default: True)

Returns:
str: Formatted string with timestamp prefix and bracket-enclosed status
"""
lines = []
parts = [f"[{timestamp}]"]

# If both resource_type and identifier are None, show a placeholder
if not self.resource_type and not self.identifier:
parts.append(
self.color_utils.make_cyan("Unknown Resource", use_color)
)
else:
if self.resource_type:
parts.append(
self.color_utils.make_cyan(self.resource_type, use_color)
)

if self.identifier:
colored_id = self.color_utils.color_by_status(
self.identifier, self.status, use_color
)
parts.append(colored_id)

if self.status:
status_text = self.color_utils.color_by_status(
self.status, self.status, use_color
)
parts.append(f"[{status_text}]")

lines.append(" ".join(parts))

if self.reason:
lines.append(f" Reason: {self.reason}")

if self.updated_at:
updated_time = datetime.fromtimestamp(
self.updated_at, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M:%SZ")
lines.append(f" Last Updated At: {updated_time}")

if self.additional_info:
lines.append(f" Info: {self.additional_info}")

return "\n".join(lines)

def combine(self, other_resource):
"""Returns the version of the resource which has the most up to date timestamp.

Expand All @@ -130,22 +182,28 @@ def combine(self, other_resource):
else other_resource
)

def diff(self, other_resource):
"""Returns a tuple of (self_diff, other_diff) for resources that are different.
def compare_properties(self, other_resource):
"""Compares individual resource properties to detect changes.

This compares properties like status, reason, updated_at, additional_info
to detect if a resource has changed between polls.

Args:
other_resource (ManagedResource): Resource to compare against

Returns:
tuple: (self_diff, other_diff) where:
- self_diff (ManagedResource): This resource if different, None if same
- other_diff (ManagedResource): Other resource if different, None if same
bool: True if properties differ, False if same
"""
if not other_resource:
return (self, None)
if (
# No previous resource means it's new/different
return True

# Resources are different if any field differs
return (
self.resource_type != other_resource.resource_type
or self.identifier != other_resource.identifier
):
return (self, other_resource)
return (None, None)
or self.status != other_resource.status
or self.reason != other_resource.reason
or self.updated_at != other_resource.updated_at
or self.additional_info != other_resource.additional_info
)
Loading