Repository: skypilot-org/skypilot (python)

Issue:
`sky show-gpus` should not assume AWS credentials to fetch AZ mappings
On a fresh docker install
```
pip install skypilot[lambda]
sky show-gpus
```
shows
```
...
I 02-21 20:24:39 aws_catalog.py:53] Fetching availability zones mapping for AWS...
--- Logging error ---
....
  File "/opt/conda/lib/python3.9/site-packages/sky/clouds/service_catalog/data_fetchers/fetch_aws.py", line 69, in get_enabled_regions
    user_cloud_regions = aws_client.describe_regions()['Regions']
  File "/opt/conda/lib/python3.9/site-packages/botocore/client.py", line 530, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/opt/conda/lib/python3.9/site-packages/botocore/client.py", line 943, in _make_api_call
    http, parsed_response = self._make_request(
  File "/opt/conda/lib/python3.9/site-packages/botocore/client.py", line 966, in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
  File "/opt/conda/lib/python3.9/site-packages/botocore/endpoint.py", line 119, in make_request
    return self._send_request(request_dict, operation_model)
  File "/opt/conda/lib/python3.9/site-packages/botocore/endpoint.py", line 198, in _send_request
    request = self.create_request(request_dict, operation_model)
  File "/opt/conda/lib/python3.9/site-packages/botocore/endpoint.py", line 134, in create_request
    self._event_emitter.emit(
  File "/opt/conda/lib/python3.9/site-packages/botocore/hooks.py", line 412, in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
  File "/opt/conda/lib/python3.9/site-packages/botocore/hooks.py", line 256, in emit
    return self._emit(event_name, kwargs)
  File "/opt/conda/lib/python3.9/site-packages/botocore/hooks.py", line 239, in _emit
    response = handler(**kwargs)
  File "/opt/conda/lib/python3.9/site-packages/botocore/signers.py", line 105, in handler
    return self.sign(operation_name, request)
  File "/opt/conda/lib/python3.9/site-packages/botocore/signers.py", line 189, in sign
    auth.add_auth(request)
  File "/opt/conda/lib/python3.9/site-packages/botocore/auth.py", line 418, in add_auth
    raise NoCredentialsError()
botocore.exceptions.NoCredentialsError: Unable to locate credentials
```
We should perhaps check other call paths that will by default fetch AWS AZ mappings.

cc @WoosukKwon @Michaelvll 

Pull request description:
Fix show gpus when user does not have AWS credentials
<!-- Describe the changes in this PR -->

Fixes #1710 

<!-- Describe the tests ran -->
<!-- Unit tests (tests/test_*.py) are part of GitHub CI; below are tests that launch on the cloud. -->

Tested (run the relevant ones):

Without credential
- [x] `sky show-gpus` (1.4 seconds; origin: 1.4 seconds)
- [x] `sky show-gpus --all`
- [x] `sky show-gpus --cloud aws --region us-east-1` (1.4 seconds; origin: 1.4 seconds)

With credential
- [x] `sky show-gpus` (1.4 seconds; origin: 1.4 seconds)
- [x] `sky show-gpus --all`
- [x] `sky show-gpus --cloud aws --region us-east-1` (1.2 seconds; origin: 1.2 seconds)


Tests:
- [x] Launch an AWS cluster; remove the AWS credentials; `sky status` and `sky status -r`
- [x] `pytest tests/test_smoke.py`
- [x] `pytest tests/test_smoke.py` with no AWS credentials
- [x] `pytest tests/test_smoke.py --aws`

Patch:
diff --git a/sky/cli.py b/sky/cli.py
index bdb246f15..c931d059b 100644
--- a/sky/cli.py
+++ b/sky/cli.py
@@ -2724,6 +2724,7 @@ def check():
     ('The region to use. If not specified, shows accelerators from all regions.'
     ),
 )
+@service_catalog.use_default_catalog
 @usage_lib.entrypoint
 def show_gpus(
         gpu_name: Optional[str],
diff --git a/sky/clouds/aws.py b/sky/clouds/aws.py
index ed5e19ba8..1d2403e92 100644
--- a/sky/clouds/aws.py
+++ b/sky/clouds/aws.py
@@ -1,7 +1,5 @@
 """Amazon Web Services."""
-
-# pylint: disable=import-outside-toplevel
-
+import functools
 import json
 import os
 import subprocess
@@ -406,10 +404,12 @@ class AWS(clouds.Cloud):
             return ([], fuzzy_candidate_list)
         return (_make(instance_list), fuzzy_candidate_list)
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    @functools.lru_cache(maxsize=1)  # Cache since getting identity is slow.
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         """Checks if the user has access credentials to this cloud."""
         try:
-            # pylint: disable=top-level-import-outside-toplevel,unused-import
+            # pylint: disable=import-outside-toplevel,unused-import
             import boto3
             import botocore
         except ImportError:
@@ -426,21 +426,21 @@ class AWS(clouds.Cloud):
             return False, (
                 'AWS CLI is not installed properly. '
                 'Run the following commands:'
-                f'\n{self._INDENT_PREFIX}  $ pip install skypilot[aws]'
-                f'{self._INDENT_PREFIX}Credentials may also need to be set. '
-                f'{self._STATIC_CREDENTIAL_HELP_STR}')
+                f'\n{cls._INDENT_PREFIX}  $ pip install skypilot[aws]'
+                f'{cls._INDENT_PREFIX}Credentials may also need to be set. '
+                f'{cls._STATIC_CREDENTIAL_HELP_STR}')
 
         # Checks if AWS credentials 1) exist and 2) are valid.
         # https://stackoverflow.com/questions/53548737/verify-aws-credentials-with-boto3
         try:
-            self.get_current_user_identity()
+            cls.get_current_user_identity()
         except exceptions.CloudUserIdentityError as e:
             return False, str(e)
 
         static_credential_exists = os.path.isfile(
             os.path.expanduser('~/.aws/credentials'))
         hints = None
-        if self._is_current_identity_sso():
+        if cls._is_current_identity_sso():
             hints = 'AWS SSO is set. '
             if static_credential_exists:
                 hints += (
@@ -459,13 +459,16 @@ class AWS(clouds.Cloud):
             # `get_current_user_identity` does not guarantee this file exists.
             if not static_credential_exists:
                 return (False, '~/.aws/credentials does not exist. ' +
-                        self._STATIC_CREDENTIAL_HELP_STR)
+                        cls._STATIC_CREDENTIAL_HELP_STR)
 
-        # Fetch the AWS availability zones mapping from ID to name.
+        # Fetch the AWS catalogs
         from sky.clouds.service_catalog import aws_catalog  # pylint: disable=import-outside-toplevel,unused-import
+        # Trigger the fetch of the availability zones mapping.
+        aws_catalog.get_default_instance_type()
         return True, hints
 
-    def _is_current_identity_sso(self) -> bool:
+    @classmethod
+    def _is_current_identity_sso(cls) -> bool:
         proc = subprocess.run('aws configure list',
                               shell=True,
                               check=False,
@@ -475,8 +478,14 @@ class AWS(clouds.Cloud):
             return False
         return 'sso' in proc.stdout.decode().split()
 
-    def get_current_user_identity(self) -> Optional[str]:
-        """Returns the identity of the user on this cloud."""
+    @classmethod
+    def get_current_user_identity(cls) -> Optional[str]:
+        """Returns the identity of the user on this cloud.
+
+        Raises:
+            exceptions.CloudUserIdentityError: if the user identity cannot be
+                retrieved.
+        """
         try:
             sts = aws.client('sts')
             # The caller identity contains 3 fields: UserId, AccountId, Arn.
@@ -492,15 +501,16 @@ class AWS(clouds.Cloud):
         except aws.botocore_exceptions().NoCredentialsError:
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
-                    f'AWS credentials are not set. {self._STATIC_CREDENTIAL_HELP_STR}'
+                    f'AWS credentials are not set. {cls._STATIC_CREDENTIAL_HELP_STR}'
                 ) from None
         except aws.botocore_exceptions().ClientError:
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
                     'Failed to access AWS services with credentials. '
                     'Make sure that the access and secret keys are correct.'
-                    f' {self._STATIC_CREDENTIAL_HELP_STR}') from None
+                    f' {cls._STATIC_CREDENTIAL_HELP_STR}') from None
         except aws.botocore_exceptions().InvalidConfigError as e:
+            # pylint: disable=import-outside-toplevel
             import awscli
             from packaging import version
             awscli_version = version.parse(awscli.__version__)
@@ -509,9 +519,9 @@ class AWS(clouds.Cloud):
                 with ux_utils.print_exception_no_traceback():
                     raise exceptions.CloudUserIdentityError(
                         'awscli is too old to use SSO. Run the following command to upgrade:'
-                        f'\n{self._INDENT_PREFIX}  $ pip install awscli>=1.27.10'
-                        f'\n{self._INDENT_PREFIX}You may need to log into SSO again after '
-                        f'upgrading. {self._sso_credentials_help_str()}'
+                        f'\n{cls._INDENT_PREFIX}  $ pip install awscli>=1.27.10'
+                        f'\n{cls._INDENT_PREFIX}You may need to log into SSO again after '
+                        f'upgrading. {cls._sso_credentials_help_str()}'
                     ) from None
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
@@ -525,8 +535,7 @@ class AWS(clouds.Cloud):
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
                     'AWS access token is expired.'
-                    f' {self._sso_credentials_help_str(expired=True)}'
-                ) from None
+                    f' {cls._sso_credentials_help_str(expired=True)}') from None
         except Exception as e:  # pylint: disable=broad-except
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
diff --git a/sky/clouds/azure.py b/sky/clouds/azure.py
index 4c3c62fdd..ed6eba657 100644
--- a/sky/clouds/azure.py
+++ b/sky/clouds/azure.py
@@ -1,4 +1,5 @@
 """Azure."""
+import functools
 import json
 import os
 import subprocess
@@ -313,7 +314,8 @@ class Azure(clouds.Cloud):
             return ([], fuzzy_candidate_list)
         return (_make(instance_list), fuzzy_candidate_list)
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         """Checks if the user has access credentials to this cloud."""
         help_str = (
             ' Run the following commands:'
@@ -341,7 +343,7 @@ class Azure(clouds.Cloud):
         # If Azure is properly logged in, this will return the account email
         # address + subscription ID.
         try:
-            self.get_current_user_identity()
+            cls.get_current_user_identity()
         except exceptions.CloudUserIdentityError:
             return False, 'Azure credential is not set.' + help_str
         return True, None
@@ -365,7 +367,9 @@ class Azure(clouds.Cloud):
         return service_catalog.accelerator_in_region_or_zone(
             accelerator, acc_count, region, zone, 'azure')
 
-    def get_current_user_identity(self) -> Optional[str]:
+    @classmethod
+    @functools.lru_cache(maxsize=1)  # Cache since getting identity is slow.
+    def get_current_user_identity(cls) -> Optional[str]:
         """Returns the cloud user identity."""
         # This returns the user's email address + [subscription_id].
         retry_cnt = 0
@@ -394,7 +398,7 @@ class Azure(clouds.Cloud):
                         f'{common_utils.format_exception(e, use_bracket=True)}'
                     ) from e
         try:
-            project_id = self.get_project_id()
+            project_id = cls.get_project_id()
         except (ModuleNotFoundError, RuntimeError) as e:
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
diff --git a/sky/clouds/cloud.py b/sky/clouds/cloud.py
index 1138b916d..50f4105b1 100644
--- a/sky/clouds/cloud.py
+++ b/sky/clouds/cloud.py
@@ -288,7 +288,8 @@ class Cloud:
         """
         raise NotImplementedError
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         """Checks if the user has access credentials to this cloud.
 
         Returns a boolean of whether the user can access this cloud, and a
@@ -296,7 +297,8 @@ class Cloud:
         """
         raise NotImplementedError
 
-    def get_current_user_identity(self) -> Optional[str]:
+    @classmethod
+    def get_current_user_identity(cls) -> Optional[str]:
         """(Advanced) Returns currently active user identity of this cloud.
 
         The user "identity" is associated with each SkyPilot cluster they
diff --git a/sky/clouds/gcp.py b/sky/clouds/gcp.py
index 0eeade140..0a284e0ff 100644
--- a/sky/clouds/gcp.py
+++ b/sky/clouds/gcp.py
@@ -1,4 +1,5 @@
 """Google Cloud Platform."""
+import functools
 import json
 import os
 import subprocess
@@ -489,7 +490,8 @@ class GCP(clouds.Cloud):
         return service_catalog.get_vcpus_from_instance_type(instance_type,
                                                             clouds='gcp')
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         """Checks if the user has access credentials to this cloud."""
         try:
             # pylint: disable=import-outside-toplevel,unused-import
@@ -510,10 +512,10 @@ class GCP(clouds.Cloud):
             _run_output('gcloud --version')
 
             # Check if application default credentials are set.
-            project_id = self.get_project_id()
+            project_id = cls.get_project_id()
 
             # Check if the user is activated.
-            self.get_current_user_identity()
+            cls.get_current_user_identity()
         except (auth.exceptions.DefaultCredentialsError,
                 subprocess.CalledProcessError,
                 exceptions.CloudUserIdentityError, FileNotFoundError,
@@ -609,7 +611,9 @@ class GCP(clouds.Cloud):
         credentials[GCP_CONFIG_SKY_BACKUP_PATH] = GCP_CONFIG_SKY_BACKUP_PATH
         return credentials
 
-    def get_current_user_identity(self) -> Optional[str]:
+    @classmethod
+    @functools.lru_cache(maxsize=1)  # Cache since getting identity is slow.
+    def get_current_user_identity(cls) -> Optional[str]:
         """Returns the email address + project id of the active user."""
         try:
             account = _run_output('gcloud auth list --filter=status:ACTIVE '
@@ -630,7 +634,7 @@ class GCP(clouds.Cloud):
                     '--format="value(account)"` and ensure it correctly '
                     'returns the current user.')
         try:
-            return f'{account} [project_id={self.get_project_id()}]'
+            return f'{account} [project_id={cls.get_project_id()}]'
         except Exception as e:  # pylint: disable=broad-except
             with ux_utils.print_exception_no_traceback():
                 raise exceptions.CloudUserIdentityError(
diff --git a/sky/clouds/lambda_cloud.py b/sky/clouds/lambda_cloud.py
index 462bfbbd8..b95019e95 100644
--- a/sky/clouds/lambda_cloud.py
+++ b/sky/clouds/lambda_cloud.py
@@ -235,7 +235,8 @@ class Lambda(clouds.Cloud):
             return ([], fuzzy_candidate_list)
         return (_make(instance_list), fuzzy_candidate_list)
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         try:
             lambda_utils.LambdaCloudClient().list_instances()
         except (AssertionError, KeyError, lambda_utils.LambdaCloudError):
@@ -253,7 +254,8 @@ class Lambda(clouds.Cloud):
             for filename in _CREDENTIAL_FILES
         }
 
-    def get_current_user_identity(self) -> Optional[str]:
+    @classmethod
+    def get_current_user_identity(cls) -> Optional[str]:
         # TODO(ewzeng): Implement get_current_user_identity for Lambda
         return None
 
diff --git a/sky/clouds/local.py b/sky/clouds/local.py
index 2bb562f92..b8554724c 100644
--- a/sky/clouds/local.py
+++ b/sky/clouds/local.py
@@ -168,7 +168,8 @@ class Local(clouds.Cloud):
     def get_zone_shell_cmd(cls) -> Optional[str]:
         return None
 
-    def check_credentials(self) -> Tuple[bool, Optional[str]]:
+    @classmethod
+    def check_credentials(cls) -> Tuple[bool, Optional[str]]:
         # This method is called in `sky check`.
         # As credentials are not generated by Sky (supplied by user instead),
         # this method will always return True.
diff --git a/sky/clouds/service_catalog/__init__.py b/sky/clouds/service_catalog/__init__.py
index 23d90faf3..81d9f1170 100644
--- a/sky/clouds/service_catalog/__init__.py
+++ b/sky/clouds/service_catalog/__init__.py
@@ -9,6 +9,7 @@ from sky.clouds.service_catalog.constants import (
     CATALOG_SCHEMA_VERSION,
     LOCAL_CATALOG_DIR,
 )
+from sky.clouds.service_catalog.config import use_default_catalog
 
 if typing.TYPE_CHECKING:
     from sky.clouds import cloud
@@ -46,6 +47,7 @@ def _map_clouds_catalog(clouds: CloudFilter, method_name: str, *args, **kwargs):
     return results
 
 
+@use_default_catalog
 def list_accelerators(
     gpus_only: bool = True,
     name_filter: Optional[str] = None,
@@ -55,6 +57,9 @@ def list_accelerators(
 ) -> 'Dict[str, List[common.InstanceTypeInfo]]':
     """List the names of all accelerators offered by Sky.
 
+    This will include all accelerators offered by Sky, including those
+    that may not be available in the user's account.
+
     Returns: A dictionary of canonical accelerator names mapped to a list
     of instance type offerings. See usage in cli.py.
     """
@@ -309,6 +314,8 @@ __all__ = [
     # Images
     'get_image_id_from_tag',
     'is_image_tag_valid',
+    # Configuration
+    'use_default_catalog',
     # Constants
     'HOSTED_CATALOG_DIR_URL',
     'CATALOG_SCHEMA_VERSION',
diff --git a/sky/clouds/service_catalog/aws_catalog.py b/sky/clouds/service_catalog/aws_catalog.py
index dfcba5084..731a10002 100644
--- a/sky/clouds/service_catalog/aws_catalog.py
+++ b/sky/clouds/service_catalog/aws_catalog.py
@@ -4,14 +4,20 @@ This module loads the service catalog file and can be used to query
 instance types and pricing information for AWS.
 """
 import colorama
+import glob
+import hashlib
 import os
+import threading
 import typing
 from typing import Dict, List, Optional, Tuple
 
 import pandas as pd
 
+from sky import exceptions
 from sky import sky_logging
+from sky.clouds import aws
 from sky.clouds.service_catalog import common
+from sky.clouds.service_catalog import config
 from sky.utils import ux_utils
 
 if typing.TYPE_CHECKING:
@@ -29,32 +35,88 @@ _DEFAULT_NUM_VCPUS = 8
 # skypilot-catalog/.github/workflows/update-aws-catalog.yml
 _PULL_FREQUENCY_HOURS = 7
 
-_df = common.read_catalog('aws/vms.csv',
-                          pull_frequency_hours=_PULL_FREQUENCY_HOURS)
+# The main catalog dataframe.
+#   - _default_df: default non-account-specific catalog
+#     The AvailabilityZone column is a zone ID (e.g. use1-az1).
+#   - _user_df: account-specific catalog (i.e., regions that the account
+#     doesn't have enabled are dropped; AZ mapping is applied, etc.)
+#     Creating this requires AWS credentials. It is created at most once
+#     (and cached) per a process' lifetime.
+#     The AvailabilityZone column is a zone name (e.g. us-east-1a).
+# `_apply_az_mapping_lock` protects reading/writing `_user_df`.
+_default_df = common.read_catalog('aws/vms.csv',
+                                  pull_frequency_hours=_PULL_FREQUENCY_HOURS)
+_user_df = None
+_apply_az_mapping_lock = threading.Lock()
+
 _image_df = common.read_catalog('aws/images.csv',
                                 pull_frequency_hours=_PULL_FREQUENCY_HOURS)
 
 
-def _apply_az_mapping(df: 'pd.DataFrame') -> 'pd.DataFrame':
+def _fetch_and_apply_az_mapping(df: pd.DataFrame) -> pd.DataFrame:
     """Maps zone IDs (use1-az1) to zone names (us-east-1x).
 
-    Such mappings are account-specific and determined by AWS.
+    The upper-level functions that use the availability zone information
+    should be able to handle the case where the zone name is not correct,
+    due to the credentials not being configured.
+
+    Such mappings are account-specific and determined by AWS. We fetch the
+    mappings from AWS, which requires AWS credentials. If the user does not
+    have AWS credentials configured, we use original zone id. It is ok to
+    use the default mapping because the user will not be able to provision
+    instances with those wrong availablity zones due to the lack of
+    credentials.
+
+    The mappings will also serve to remove from 'df' the regions that are
+    not supported by the user account.
 
     Returns:
         A dataframe with column 'AvailabilityZone' that's correctly replaced
         with the zone name (e.g. us-east-1a).
     """
-    az_mapping_path = common.get_catalog_path('aws/az_mappings.csv')
+    try:
+        user_identity = aws.AWS.get_current_user_identity()
+        assert user_identity is not None, 'user_identity is None'
+        aws_user_hash = hashlib.md5(user_identity.encode()).hexdigest()[:8]
+    except exceptions.CloudUserIdentityError:
+        glob_name = common.get_catalog_path('aws/az_mappings-*.csv')
+        # Find the most recent file that matches the glob.
+        # We check the existing files because the user could remove the
+        # credentials after a cluster is created. Using the latest mapping
+        # file is better than using the default mapping file because the
+        # former is more likely to be correct.
+        glob_files = glob.glob(glob_name)
+        if glob_files:
+            glob_files.sort(key=os.path.getmtime)
+            aws_user_hash = os.path.basename(glob_files[-1]).split('-')[1]
+            # aws_user_hash can be set to `default` if the user never
+            # configured AWS credentials.
+            aws_user_hash = aws_user_hash.split('.')[0]
+        else:
+            aws_user_hash = 'default'
+        logger.debug(
+            'Failed to get AWS user identity. Using the latest mapping '
+            f'file for user {aws_user_hash!r}.')
+
+    az_mapping_path = common.get_catalog_path(
+        f'aws/az_mappings-{aws_user_hash}.csv')
     if not os.path.exists(az_mapping_path):
-        # Fetch az mapping from AWS.
-        # pylint: disable=import-outside-toplevel
-        import ray
-        from sky.clouds.service_catalog.data_fetchers import fetch_aws
-        logger.info(f'{colorama.Style.DIM}Fetching availability zones mapping '
-                    f'for AWS...{colorama.Style.RESET_ALL}')
-        with ux_utils.suppress_output():
-            ray.init()
-        az_mappings = fetch_aws.fetch_availability_zone_mappings()
+        az_mappings = None
+        if aws_user_hash != 'default':
+            # Fetch az mapping from AWS.
+            # pylint: disable=import-outside-toplevel
+            import ray
+            from sky.clouds.service_catalog.data_fetchers import fetch_aws
+            logger.info(f'{colorama.Style.DIM}Fetching availability zones '
+                        f'mapping for AWS...{colorama.Style.RESET_ALL}')
+            with ux_utils.suppress_output():
+                ray.init()
+            az_mappings = fetch_aws.fetch_availability_zone_mappings()
+        else:
+            # Returning the original dataframe directly, as no cloud
+            # identity can be fetched which suggests there are no
+            # credentials.
+            return df
         az_mappings.to_csv(az_mapping_path, index=False)
     else:
         az_mappings = pd.read_csv(az_mapping_path)
@@ -67,50 +129,60 @@ def _apply_az_mapping(df: 'pd.DataFrame') -> 'pd.DataFrame':
     return df
 
 
-_df = _apply_az_mapping(_df)
+def _get_df() -> pd.DataFrame:
+    if config.get_use_default_catalog():
+        return _default_df
+    else:
+        global _user_df
+        with _apply_az_mapping_lock:
+            if _user_df is None:
+                _user_df = _fetch_and_apply_az_mapping(_default_df)
+        return _user_df
 
 
 def instance_type_exists(instance_type: str) -> bool:
-    return common.instance_type_exists_impl(_df, instance_type)
+    return common.instance_type_exists_impl(_get_df(), instance_type)
 
 
 def validate_region_zone(
         region: Optional[str],
         zone: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
-    return common.validate_region_zone_impl('aws', _df, region, zone)
+    return common.validate_region_zone_impl('aws', _get_df(), region, zone)
 
 
 def accelerator_in_region_or_zone(acc_name: str,
                                   acc_count: int,
                                   region: Optional[str] = None,
                                   zone: Optional[str] = None) -> bool:
-    return common.accelerator_in_region_or_zone_impl(_df, acc_name, acc_count,
-                                                     region, zone)
+    return common.accelerator_in_region_or_zone_impl(_get_df(), acc_name,
+                                                     acc_count, region, zone)
 
 
 def get_hourly_cost(instance_type: str,
                     use_spot: bool = False,
                     region: Optional[str] = None,
                     zone: Optional[str] = None) -> float:
-    return common.get_hourly_cost_impl(_df, instance_type, use_spot, region,
-                                       zone)
+    return common.get_hourly_cost_impl(_get_df(), instance_type, use_spot,
+                                       region, zone)
 
 
 def get_vcpus_from_instance_type(instance_type: str) -> Optional[float]:
-    return common.get_vcpus_from_instance_type_impl(_df, instance_type)
+    return common.get_vcpus_from_instance_type_impl(_get_df(), instance_type)
 
 
 def get_default_instance_type(cpus: Optional[str] = None) -> Optional[str]:
     if cpus is None:
         cpus = str(_DEFAULT_NUM_VCPUS)
     instance_type_prefix = f'{_DEFAULT_INSTANCE_FAMILY}.'
-    df = _df[_df['InstanceType'].str.startswith(instance_type_prefix)]
+    df = _get_df()
+    df = df[df['InstanceType'].str.startswith(instance_type_prefix)]
     return common.get_instance_type_for_cpus_impl(df, cpus)
 
 
 def get_accelerators_from_instance_type(
         instance_type: str) -> Optional[Dict[str, int]]:
-    return common.get_accelerators_from_instance_type_impl(_df, instance_type)
+    return common.get_accelerators_from_instance_type_impl(
+        _get_df(), instance_type)
 
 
 def get_instance_type_for_accelerator(
@@ -125,7 +197,7 @@ def get_instance_type_for_accelerator(
     Returns a list of instance types satisfying the required count of
     accelerators with sorted prices and a list of candidates with fuzzy search.
     """
-    return common.get_instance_type_for_accelerator_impl(df=_df,
+    return common.get_instance_type_for_accelerator_impl(df=_get_df(),
                                                          acc_name=acc_name,
                                                          acc_count=acc_count,
                                                          cpus=cpus,
@@ -136,7 +208,8 @@ def get_instance_type_for_accelerator(
 
 def get_region_zones_for_instance_type(instance_type: str,
                                        use_spot: bool) -> List['cloud.Region']:
-    df = _df[_df['InstanceType'] == instance_type]
+    df = _get_df()
+    df = df[df['InstanceType'] == instance_type]
     region_list = common.get_region_zones(df, use_spot)
     # Hack: Enforce US regions are always tried first:
     #   [US regions sorted by price] + [non-US regions sorted by price]
@@ -157,8 +230,9 @@ def list_accelerators(
         case_sensitive: bool = True
 ) -> Dict[str, List[common.InstanceTypeInfo]]:
     """Returns all instance types in AWS offering accelerators."""
-    return common.list_accelerators_impl('AWS', _df, gpus_only, name_filter,
-                                         region_filter, case_sensitive)
+    return common.list_accelerators_impl('AWS', _get_df(), gpus_only,
+                                         name_filter, region_filter,
+                                         case_sensitive)
 
 
 def get_image_id_from_tag(tag: str, region: Optional[str]) -> Optional[str]:
diff --git a/sky/clouds/service_catalog/config.py b/sky/clouds/service_catalog/config.py
new file mode 100644
index 000000000..50e693246
--- /dev/null
+++ b/sky/clouds/service_catalog/config.py
@@ -0,0 +1,43 @@
+"""Configuration for service catalog."""
+
+import contextlib
+import functools
+import threading
+
+_thread_local_config = threading.local()
+# Whether the caller requires the catalog to be narrowed down
+# to the account-specific catalog (e.g., removing regions not
+# enabled for the current account) or just the raw catalog
+# fetched from SkyPilot catalog service. The former is used
+# for launching clusters, while the latter for commands like
+# `show-gpus`.
+_thread_local_config.use_default_catalog = False
+
+
+@contextlib.contextmanager
+def _set_use_default_catalog(value: bool):
+    old_value = _thread_local_config.use_default_catalog
+    _thread_local_config.use_default_catalog = value
+    try:
+        yield
+    finally:
+        _thread_local_config.use_default_catalog = old_value
+
+
+def get_use_default_catalog() -> bool:
+    return _thread_local_config.use_default_catalog
+
+
+def use_default_catalog(func):
+    """Decorator: disable fetching account-specific catalog.
+
+    The account-specific catalog requires the credentials of the
+    cloud to be set.
+    """
+
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        with _set_use_default_catalog(True):
+            return func(*args, **kwargs)
+
+    return wrapper
diff --git a/sky/clouds/service_catalog/data_fetchers/fetch_aws.py b/sky/clouds/service_catalog/data_fetchers/fetch_aws.py
index cb7fed40f..f8e4b6ba8 100644
--- a/sky/clouds/service_catalog/data_fetchers/fetch_aws.py
+++ b/sky/clouds/service_catalog/data_fetchers/fetch_aws.py
@@ -48,6 +48,7 @@ ALL_REGIONS = [
 ]
 US_REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2']
 
+# The following columns will be included in the final catalog.
 USEFUL_COLUMNS = [
     'InstanceType', 'AcceleratorName', 'AcceleratorCount', 'vCPUs', 'MemoryGiB',
     'GpuInfo', 'Price', 'SpotPrice', 'Region', 'AvailabilityZone'
diff --git a/sky/utils/db_utils.py b/sky/utils/db_utils.py
index 6b331a71c..4b4d4a4cb 100644
--- a/sky/utils/db_utils.py
+++ b/sky/utils/db_utils.py
@@ -16,8 +16,16 @@ def add_column_to_table(
         if row[1] == column_name:
             break
     else:
-        cursor.execute(f'ALTER TABLE {table_name} '
-                       f'ADD COLUMN {column_name} {column_type}')
+        try:
+            cursor.execute(f'ALTER TABLE {table_name} '
+                           f'ADD COLUMN {column_name} {column_type}')
+        except sqlite3.OperationalError as e:
+            if 'duplicate column name' in str(e):
+                # We may be trying to add the same column twice, when
+                # running multiple threads. This is fine.
+                pass
+            else:
+                raise
     conn.commit()
 
 
@@ -45,6 +53,8 @@ class SQLiteConn(threading.local):
     def __init__(self, db_path: str, create_table: Callable):
         super().__init__()
         self.db_path = db_path
-        self.conn = sqlite3.connect(db_path)
+        # NOTE: We use a timeout of 10 seconds to avoid database locked
+        # errors. This is a hack, but it works.
+        self.conn = sqlite3.connect(db_path, timeout=10)
         self.cursor = self.conn.cursor()
         create_table(self.cursor, self.conn)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b37b75412..ed83f8d1e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
 import os
+import pathlib
 import pytest
 import tempfile
 import textwrap
@@ -153,71 +154,23 @@ def generic_cloud(request) -> str:
     return _generic_cloud(request.config)
 
 
-def pytest_sessionstart(session):
-    from sky.clouds.service_catalog import common
-    aws_az_mapping_path = common.get_catalog_path('aws/az_mappings.csv')
-
-    if not os.path.exists(aws_az_mapping_path):
-        try:
-            # Try to fetch the AZ mapping from AWS (if we have AWS access)
-            from sky.clouds.service_catalog import aws_catalog
-        except:
-            # If we don't have AWS access, create a dummy file
-            with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
-                f.write(
-                    textwrap.dedent("""\
-                    AvailabilityZoneName,AvailabilityZone
-                    us-east-1a,use1-az2
-                    us-east-1b,use1-az4
-                    us-east-1c,use1-az6
-                    us-east-1d,use1-az1
-                    us-east-1e,use1-az3
-                    us-east-1f,use1-az5
-                    us-east-2a,use2-az1
-                    us-east-2b,use2-az2
-                    us-east-2c,use2-az3
-                    us-west-1a,usw1-az1
-                    us-west-1c,usw1-az3
-                    us-west-2a,usw2-az1
-                    us-west-2b,usw2-az2
-                    us-west-2c,usw2-az3
-                    us-west-2d,usw2-az4
-                    ca-central-1a,cac1-az1
-                    ca-central-1b,cac1-az2
-                    ca-central-1d,cac1-az4
-                    eu-central-1a,euc1-az2
-                    eu-central-1b,euc1-az3
-                    eu-central-1c,euc1-az1
-                    eu-west-1a,euw1-az3
-                    eu-west-1b,euw1-az1
-                    eu-west-1c,euw1-az2
-                    eu-west-2a,euw2-az2
-                    eu-west-2b,euw2-az3
-                    eu-west-2c,euw2-az1
-                    eu-west-3a,euw3-az1
-                    eu-west-3b,euw3-az2
-                    eu-west-3c,euw3-az3
-                    eu-north-1a,eun1-az1
-                    eu-north-1b,eun1-az2
-                    eu-north-1c,eun1-az3
-                    ap-south-1a,aps1-az1
-                    ap-south-1b,aps1-az3
-                    ap-south-1c,aps1-az2
-                    ap-northeast-3a,apne3-az3
-                    ap-northeast-3b,apne3-az1
-                    ap-northeast-3c,apne3-az2
-                    ap-northeast-2a,apne2-az1
-                    ap-northeast-2b,apne2-az2
-                    ap-northeast-2c,apne2-az3
-                    ap-northeast-2d,apne2-az4
-                    ap-southeast-1a,apse1-az2
-                    ap-southeast-1b,apse1-az1
-                    ap-southeast-1c,apse1-az3
-                    ap-southeast-2a,apse2-az1
-                    ap-southeast-2b,apse2-az3
-                    ap-southeast-2c,apse2-az2
-                    ap-northeast-1a,apne1-az4
-                    ap-northeast-1c,apne1-az1
-                    ap-northeast-1d,apne1-az2
-                """))
-            os.replace(f.name, aws_az_mapping_path)
+@pytest.fixture
+def enable_all_clouds(monkeypatch):
+    from sky import clouds
+    # Monkey-patching is required because in the test environment, no cloud is
+    # enabled. The optimizer checks the environment to find enabled clouds, and
+    # only generates plans within these clouds. The tests assume that all three
+    # clouds are enabled, so we monkeypatch the `sky.global_user_state` module
+    # to return all three clouds. We also monkeypatch `sky.check.check` so that
+    # when the optimizer tries calling it to update enabled_clouds, it does not
+    # raise exceptions.
+    enabled_clouds = list(clouds.CLOUD_REGISTRY.values())
+    monkeypatch.setattr(
+        'sky.global_user_state.get_enabled_clouds',
+        lambda: enabled_clouds,
+    )
+    monkeypatch.setattr('sky.check.check', lambda *_args, **_kwargs: None)
+    config_file_backup = tempfile.NamedTemporaryFile(
+        prefix='tmp_backup_config_default', delete=False)
+    monkeypatch.setattr('sky.clouds.gcp.GCP_CONFIG_SKY_BACKUP_PATH',
+                        config_file_backup.name)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index eff7edf37..21e9dd8e0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -41,25 +41,8 @@ def test_infer_tpunode_type():
         assert cli._infer_interactive_node_type(spec) == 'tpunode', spec
 
 
-def test_accelerator_mismatch(monkeypatch):
+def test_accelerator_mismatch(enable_all_clouds):
     """Test the specified accelerator does not match the instance_type."""
-    # Monkey-patching is required because in the test environment, no cloud is
-    # enabled. The optimizer checks the environment to find enabled clouds, and
-    # only generates plans within these clouds. The tests assume that all three
-    # clouds are enabled, so we monkeypatch the `sky.global_user_state` module
-    # to return all three clouds. We also monkeypatch `sky.check.check` so that
-    # when the optimizer tries calling it to update enabled_clouds, it does not
-    # raise exceptions.
-    enabled_clouds = list(clouds.CLOUD_REGISTRY.values())
-    monkeypatch.setattr(
-        'sky.global_user_state.get_enabled_clouds',
-        lambda: enabled_clouds,
-    )
-    monkeypatch.setattr('sky.check.check', lambda *_args, **_kwargs: None)
-    config_file_backup = tempfile.NamedTemporaryFile(
-        prefix='tmp_backup_config_default', delete=False)
-    monkeypatch.setattr('sky.clouds.gcp.GCP_CONFIG_SKY_BACKUP_PATH',
-                        config_file_backup.name)
 
     spec = textwrap.dedent("""\
         resources:
diff --git a/tests/test_optimizer_dryruns.py b/tests/test_optimizer_dryruns.py
index 16fd6c6e9..b30a7ae55 100644
--- a/tests/test_optimizer_dryruns.py
+++ b/tests/test_optimizer_dryruns.py
@@ -334,13 +334,13 @@ def test_valid_image(monkeypatch):
         cloud=sky.GCP(),
         region='us-central1',
         image_id=
-        'projects/deeplearning-platform-release/global/images/family/common-cpu'
+        'projects/deeplearning-platform-release/global/images/family/common-cpu-v20230126'
     )
     _test_resources(
         monkeypatch,
         cloud=sky.GCP(),
         image_id=
-        'projects/deeplearning-platform-release/global/images/family/common-cpu'
+        'projects/deeplearning-platform-release/global/images/family/common-cpu-v20230126'
     )
 
 
diff --git a/tests/test_optimizer_random_dag.py b/tests/test_optimizer_random_dag.py
index 4e04adabf..458faf927 100644
--- a/tests/test_optimizer_random_dag.py
+++ b/tests/test_optimizer_random_dag.py
@@ -123,13 +123,7 @@ def compare_optimization_results(dag: sky.Dag, minimize_cost: bool):
     assert objective == min_objective
 
 
-def test_optimizer(monkeypatch):
-    enabled_clouds = list(CLOUDS.values())
-    monkeypatch.setattr(
-        'sky.global_user_state.get_enabled_clouds',
-        lambda: enabled_clouds,
-    )
-    monkeypatch.setattr('sky.check.check', lambda *_args, **_kwargs: None)
+def test_optimizer(enable_all_clouds):
 
     dag = generate_random_dag(num_tasks=5, seed=0)
     sky.Optimizer._add_dummy_source_sink_nodes(dag)