Repository: aws/aws-cli

Issue:
aws ecr describe-repositories fails with linking error
### Describe the bug

Our CI/CD pipeline began failing roughly when v2.26 was published with the following error:

```bash
[Publishing Runtime Image] Checking if ECR repository exists
[15:52:40][Publishing Runtime Image] less: /usr/local/aws-cli/v2/2.26.0/dist/libtinfo.so.5: no version information available (required by less)
[15:52:40][Publishing Runtime Image] 
[15:52:40][Publishing Runtime Image] less: Symbol `ospeed' has different size in shared object, consider re-linking
[15:52:40][Publishing Runtime Image] 
[15:52:40][Publishing Runtime Image] WARNING: terminal is not fully functional
```

The command we're running is `aws ecr describe-repositories --repository-names $service_lower --region us-east-1`

It seems the latest cli release may not have the correct dependencies?

### Regression Issue

- [x] Select this option if this issue appears to be a regression.

### Expected Behavior

For the command to work and return whether or not a repository exists.

### Current Behavior

It throws the exception mentioned and hangs, waiting for user input. 

### Reproduction Steps

run `aws ecr describe-repositories --repository-names <repo_name> --region us-east-1`

### Possible Solution

Revert to 2.25

### Additional Information/Context

_No response_

### CLI version used

2.2.6

### Environment details (OS name and version, etc.)

ubuntu 20.x

Hints from issue comments:
@codeimpossible: Our workaround ended up being to downgrade to v2.25.13@hssyoo: Thanks for opening this issue. Version 2.26.0 upgraded the bundled Python interpreter from 3.12 to 3.13. This caused the Linux installers to dynamically link `libtinfo.so.5`, which is bundled with the Linux installers.

Can you provide more details on the environment? Specifically, are there configurations set that may be changing dynamic linking behavior? Ubuntu is part of our build system's test matrix, I'm wondering what differences in the environment may be the cause.@codeimpossible: hi @hssyoo , I originally listed Ubuntu 20.x as the version our CI was running, but i was in the middle of firefighting and that's not correct. We run our CI in a containerized pipeline and the container this failed under is `ubuntu:18.04`. I've included the relevant lines from our dockerfile below.

```dockerfile
FROM ubuntu:18.04

## Install Wget, Gpg, Curl
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
    wget \
    apt-transport-https ca-certificates curl \
    gnupg2 software-properties-common python unzip \
    rsync

## Install Docker
RUN mkdir -p /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
RUN apt-get update \
    && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin \
    && docker --version || (echo "docker was not installed correctly!" && exit 1)

## Install AWS CLI
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
    && unzip awscliv2.zip \
    && ./aws/install \
    && aws --version || (echo "aws cli was not installed correctly!" && exit 1)

## Install docker qemu-user-static for multi architecture support
RUN apt-get install -y qemu-user-static
```

The host machines for these containers are x86_64, but we use them to build multi-arch images, hence the install of qemu, but the rest of the system should be x86_64, so im not sure exactly what broke. Perhaps the older Ubuntu version is to blame?@hssyoo: Thanks for the additional info @codeimpossible, I was able to reproduce it on an ARM Ubuntu 18.04 image.

For some background, the AWS CLI binaries are built using PyInstaller, which also helps package shared libraries needed at runtime. We try to bundle shared libraries so the AWS CLI works on systems that don't already have them. When an application built with PyInstaller launches, it sets the `LD_LIBRARY_PATH` to the directory with the bundled shared libraries. What this means is that all instructions executed under the AWS CLI's Python process will first look for required shared libraries in this directory. This helps systems run with dependencies that the AWS CLI was built and tested with.

The root cause here is that the AWS CLI is configured to output responses to `less`, which also has a dependency on `libtinfo`. The issue is that AWS CLI is using a different version of `libtinfo` that's incompatible with the system's `less` program. Although `less` is owned by the system, because it's being called within the Python process, it's inheriting PyInstaller's `LD_LIBRARY_PATH`. In short, this causes the system's `less` to use the AWS CLI's `libtinfo` at runtime, causing this issue.

In the short-term, you can unblock your CI/CD pipeline by continuing to use the last known working version, **or** you can set the environment variable `AWS_PAGER` to `cat` (ie `export AWS_PAGER=cat`). This configures the AWS CLI to output responses to `cat` instead of `less`.

The team is looking into our options for mitigating this at source. We'll continue to provide updates here.@hssyoo: @codeimpossible The fix was merged and released in the latest version (v2.26.2).

Solution patch:
diff --git a/awscli/utils.py b/awscli/utils.py
index b35d4b10a54b..eb8efcf855d6 100644
--- a/awscli/utils.py
+++ b/awscli/utils.py
@@ -25,6 +25,7 @@
 from botocore.utils import (
     BadIMDSRequestError,
     IMDSFetcher,
+    original_ld_library_path,
     resolve_imds_endpoint_mode,
 )
 
@@ -415,7 +416,11 @@ def __init__(
             self._popen = Popen
         self._environ = environ
         if environ is None:
-            self._environ = os.environ.copy()
+            # When calling out to the system's pager, we want to avoid using
+            # shared libraries bundled with the AWS CLI so that the pager uses
+            # the system's shared libraries.
+            with original_ld_library_path():
+                self._environ = os.environ.copy()
         self._default_less_flags = default_less_flags
 
     def get_output_stream(self):


Test patch:
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 9eb5d9c1e0e4..212a4f521a24 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -343,6 +343,53 @@ def test_not_create_process_if_stream_not_created(self):
             self.assertEqual(self.popen.call_count, 0)
         self.assertEqual(self.popen.call_count, 0)
 
+    def test_pyinstaller_ld_library_path_removed(self):
+        # Simulate PyInstaller setting its own LD_LIBRARY_PATH.
+        with mock.patch(
+            'os.environ', {'LD_LIBRARY_PATH': '/pyinstaller/path'}
+        ):
+            stream_factory = OutputStreamFactory(
+                session=self.session,
+                popen=self.popen,
+            )
+        self.set_session_pager('less')
+        with stream_factory.get_output_stream() as stream:
+            stream.write()
+            # Assert that PyInstaller's LD_LIBRARY_PATH was removed.
+            self.assert_popen_call(
+                expected_pager_cmd='less',
+                env={'PYINSTALLER_RESET_ENVIRONMENT': '1', 'LESS': 'FRX'},
+            )
+
+    def test_pyinstaller_ld_library_path_replaced_with_original(self):
+        # Simulate PyInstaller setting its own LD_LIBRARY_PATH and
+        # preserving system value as LD_LIBRARY_PATH_ORIG.
+        with mock.patch(
+            'os.environ',
+            {
+                'LD_LIBRARY_PATH': '/pyinstaller/path',
+                'LD_LIBRARY_PATH_ORIG': '/original/path',
+            },
+        ):
+            stream_factory = OutputStreamFactory(
+                session=self.session,
+                popen=self.popen,
+            )
+        self.set_session_pager('less')
+        with stream_factory.get_output_stream() as stream:
+            stream.write()
+            # Assert that PyInstaller's LD_LIBRARY_PATH was replaced
+            # with the original value.
+            self.assert_popen_call(
+                expected_pager_cmd='less',
+                env={
+                    'LD_LIBRARY_PATH': '/original/path',
+                    'LD_LIBRARY_PATH_ORIG': '/original/path',
+                    'PYINSTALLER_RESET_ENVIRONMENT': '1',
+                    'LESS': 'FRX',
+                },
+            )
+
 
 class BaseIMDSRegionTest(unittest.TestCase):
     def setUp(self):