This is an automated email from the ASF dual-hosted git repository.

yzheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new f80adc5a28 Add setup options for CLI (#3929)
f80adc5a28 is described below

commit f80adc5a28d78b3e1bb8bc1773ef11173f2c4a83
Author: Yong Zheng <[email protected]>
AuthorDate: Tue Mar 10 13:53:19 2026 -0500

    Add setup options for CLI (#3929)
---
 CHANGELOG.md                                       |    1 +
 .../python/apache_polaris/cli/command/__init__.py  |    9 +
 client/python/apache_polaris/cli/command/setup.py  | 1386 ++++++++++++++++++++
 client/python/apache_polaris/cli/constants.py      |    8 +
 .../apache_polaris/cli/options/option_tree.py      |   12 +
 client/python/pyproject.toml                       |    1 +
 client/python/uv.lock                              |    2 +
 .../assets/polaris/reference-setup-config.yaml     |  226 ++++
 .../guides/assets/polaris/simple-setup-config.yaml |   48 +
 .../in-dev/unreleased/command-line-interface.md    |   48 +
 10 files changed, 1741 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cb8a85a01..c525dca1a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,7 @@ request adding CHANGELOG notes for breaking (!) changes and 
possibly other secti
 - Added support for S3-compatible storage that does not have KMS (use 
`kmsUavailable: true` in catalog storage configuration)
 - Added support for storage-scoped AWS credentials, allowing different AWS 
credentials to be configured per named storage. Enable with the 
`RESOLVE_CREDENTIALS_BY_STORAGE_NAME` feature flag (default: false). Storage 
names can be set explicitly via the `storageName` field on storage 
configuration, or inferred from the first allowed location's host.
 - Added support for persisting Iceberg metrics (ScanReport, CommitReport) to 
the database. Enable by setting 
`polaris.iceberg-metrics.reporting.type=persisting` in configuration. Metrics 
tables are included in the main JDBC schema.
+- Added setup options to Polaris CLI.
 - Added CockroachDB as a supported database for the relational JDBC 
persistence backend. Set `polaris.persistence.relational.jdbc.database-type` to 
`cockroachdb` to get started.
 
 ### Changes
diff --git a/client/python/apache_polaris/cli/command/__init__.py 
b/client/python/apache_polaris/cli/command/__init__.py
index 7b2deb0c71..183ceadd2d 100644
--- a/client/python/apache_polaris/cli/command/__init__.py
+++ b/client/python/apache_polaris/cli/command/__init__.py
@@ -210,6 +210,15 @@ class Command(ABC):
                 attachment_type=options_get(Arguments.ATTACHMENT_TYPE),
                 attachment_path=options_get(Arguments.ATTACHMENT_PATH),
             )
+        elif options.command == Commands.SETUP:
+            from apache_polaris.cli.command.setup import SetupCommand
+
+            subcommand = options_get(f"{Commands.SETUP}_subcommand")
+            command = SetupCommand(
+                subcommand,
+                setup_config=options_get(Arguments.SETUP_CONFIG),
+                dry_run=options_get(Arguments.DRY_RUN),
+            )
 
         if command is not None:
             command.validate()
diff --git a/client/python/apache_polaris/cli/command/setup.py 
b/client/python/apache_polaris/cli/command/setup.py
new file mode 100644
index 0000000000..d8ad20d2f0
--- /dev/null
+++ b/client/python/apache_polaris/cli/command/setup.py
@@ -0,0 +1,1386 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License 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.
+#
+import os
+import logging
+import yaml
+import json
+from dataclasses import dataclass
+from typing import Dict, Optional, List, Any, Set, cast
+
+from apache_polaris.cli.command import Command
+from apache_polaris.cli.constants import (
+    PrincipalType,
+    Subcommands,
+    StorageType,
+    UNIT_SEPARATOR,
+    Actions,
+    CatalogType,
+    CatalogConnectionType,
+    AuthenticationType,
+    ServiceIdentityType,
+)
+
+from apache_polaris.sdk.management import PolarisDefaultApi
+from apache_polaris.sdk.catalog import IcebergCatalogAPI
+from apache_polaris.sdk.catalog.api.policy_api import PolicyAPI
+from apache_polaris.sdk.catalog.exceptions import NotFoundException
+from apache_polaris.sdk.catalog.models.create_policy_request import 
CreatePolicyRequest
+from apache_polaris.cli.command.principals import PrincipalsCommand
+from apache_polaris.cli.command.principal_roles import PrincipalRolesCommand
+from apache_polaris.cli.command.catalogs import CatalogsCommand
+from apache_polaris.cli.command.catalog_roles import CatalogRolesCommand
+from apache_polaris.cli.command.namespaces import NamespacesCommand
+from apache_polaris.cli.command.privileges import PrivilegesCommand
+from apache_polaris.cli.command.policies import PoliciesCommand
+from apache_polaris.cli.command.utils import get_catalog_api_client
+
+logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", 
level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SetupCommand(Command):
+    setup_subcommand: str
+    setup_config: Optional[str] = None
+    dry_run: bool = False
+    _config_cache: Optional[Dict[str, Any]] = None
+    _existing_principal_roles: Optional[Set[str]] = None
+    _existing_catalog_roles: Optional[Dict[str, Set[str]]] = None
+    _existing_catalogs: Optional[Set[str]] = None
+    _existing_principals: Optional[Set[str]] = None
+    _catalog_api: Optional[Any] = None
+
+    def _get_catalog_api(self, api: PolarisDefaultApi) -> Any:
+        """Get or create and cache the IcebergCatalogAPI client."""
+        if self._catalog_api is None:
+            self._catalog_api = get_catalog_api_client(api)
+        return self._catalog_api
+
+    def _get_existing_principals(self, api: PolarisDefaultApi) -> Set[str]:
+        """Fetch and cache the set of existing principal names."""
+        if self._existing_principals is None:
+            try:
+                self._existing_principals = {
+                    p.name for p in api.list_principals().principals
+                }
+            except Exception:
+                logger.exception("Failed to fetch existing principals")
+                self._existing_principals = set()
+        return self._existing_principals
+
+    def _get_existing_principal_roles(self, api: PolarisDefaultApi) -> 
Set[str]:
+        """Fetch and cache the set of existing principal role names."""
+        if self._existing_principal_roles is None:
+            try:
+                self._existing_principal_roles = {
+                    role.name for role in api.list_principal_roles().roles
+                }
+            except Exception:
+                logger.exception("Failed to fetch existing principal roles")
+                self._existing_principal_roles = set()
+        return self._existing_principal_roles
+
+    def _get_existing_catalog_roles(
+        self, api: PolarisDefaultApi, catalog_name: Optional[str] = None
+    ) -> Set[str]:
+        """Fetch and cache the set of existing catalog role names for a given 
catalog."""
+        if self._existing_catalog_roles is None:
+            self._existing_catalog_roles = {}
+
+        if catalog_name:
+            if catalog_name not in self._existing_catalog_roles:
+                try:
+                    roles = api.list_catalog_roles(catalog_name).roles
+                    self._existing_catalog_roles[catalog_name] = {
+                        role.name for role in roles
+                    }
+                except Exception as e:
+                    # In dry-run, a 404 is expected if the catalog doesn't 
exist yet
+                    is_404 = getattr(e, "status", None) == 404 or "(404)" in 
str(e)
+                    if not (self.dry_run and is_404):
+                        logger.warning(
+                            f"Failed to fetch catalog roles for catalog 
'{catalog_name}': {e}"
+                        )
+                    self._existing_catalog_roles[catalog_name] = set()
+            return self._existing_catalog_roles[catalog_name]
+        return set()
+
+    def _get_existing_catalogs(self, api: PolarisDefaultApi) -> Set[str]:
+        """Fetch and cache the set of existing catalog names."""
+        if self._existing_catalogs is None:
+            try:
+                self._existing_catalogs = {c.name for c in 
api.list_catalogs().catalogs}
+            except Exception:
+                logger.exception("Failed to fetch existing catalogs")
+                self._existing_catalogs = set()
+        return self._existing_catalogs
+
+    def _export_config(self, api: PolarisDefaultApi) -> None:
+        """Export the current Polaris configuration to YAML."""
+        logger.info("--- Exporting Polaris Configuration ---")
+        logger.warning(
+            "Exporting policy attachments is not currently supported due to 
performance "
+            "considerations. Attachments will be omitted from the exported 
configuration."
+        )
+        config = {
+            "principals": self._export_principals(api),
+            "principal_roles": self._export_principal_roles(api),
+            "catalogs": self._export_catalogs(api),
+        }
+        print(yaml.safe_dump(config, sort_keys=False, indent=2).rstrip())
+        logger.info("--- Finished Exporting Polaris Configuration ---")
+
+    def _export_principals(self, api: PolarisDefaultApi) -> Dict[str, Any]:
+        """Export all principals and their assigned roles."""
+        principals_map: Dict[str, Any] = {}
+        try:
+            principals = sorted(api.list_principals().principals, key=lambda 
p: p.name)
+            for p in principals:
+                principal_info: Dict[str, Any] = {"type": 
PrincipalType.SERVICE.value}
+                try:
+                    assigned_roles = 
api.list_principal_roles_assigned(p.name).roles
+                    if assigned_roles:
+                        principal_info["roles"] = sorted(
+                            [r.name for r in assigned_roles]
+                        )
+                except Exception:
+                    logger.warning(f"Failed to export roles for principal 
'{p.name}'")
+                principals_map[p.name] = principal_info
+        except Exception:
+            logger.exception("Failed to export principals")
+        return principals_map
+
+    def _export_principal_roles(self, api: PolarisDefaultApi) -> List[str]:
+        """Export all principal role names."""
+        try:
+            return sorted([role.name for role in 
api.list_principal_roles().roles])
+        except Exception:
+            logger.exception("Failed to export principal roles")
+            return []
+
+    def _serialize_authentication_info(self, auth_params: Any) -> Dict[str, 
Any]:
+        """Serializes authentication parameters into a dictionary for YAML 
export."""
+        if not auth_params:
+            return {}
+        auth_type = auth_params.authentication_type.lower()
+        auth_data = {"type": auth_type}
+        if auth_type == AuthenticationType.OAUTH.value:
+            auth_data.update(
+                {
+                    "token_uri": auth_params.token_uri,
+                    "client_id": auth_params.client_id,
+                    "client_secret": 
auth_params.client_secret.get_secret_value()
+                    if auth_params.client_secret
+                    else None,
+                    "scopes": auth_params.scopes,
+                }
+            )
+        elif auth_type == AuthenticationType.BEARER.value:
+            auth_data["token"] = (
+                auth_params.bearer_token.get_secret_value()
+                if auth_params.bearer_token
+                else None
+            )
+        elif auth_type == AuthenticationType.SIGV4.value:
+            auth_data.update(
+                {
+                    "role_arn": auth_params.role_arn,
+                    "role_session_name": auth_params.role_session_name,
+                    "external_id": auth_params.external_id,
+                    "signing_region": auth_params.signing_region,
+                    "signing_name": auth_params.signing_name,
+                }
+            )
+        # Filtered out None or empty values for cleaner YAML output
+        filtered_auth_data = {
+            k: v for k, v in auth_data.items() if v is not None and v != []
+        }
+        return {"authentication": filtered_auth_data} if filtered_auth_data 
else {}
+
+    def _serialize_service_identity_info(self, identity_info: Any) -> 
Dict[str, Any]:
+        """Serializes service identity info into a dictionary for YAML 
export."""
+        if not identity_info:
+            return {}
+        identity_type = identity_info.identity_type.lower()
+        identity_data = {"type": identity_type}
+        if identity_type == ServiceIdentityType.AWS_IAM.value:
+            identity_data["iam_arn"] = identity_info.iam_arn
+        # Filtered out None or empty values for cleaner YAML output
+        filtered_identity_data = {
+            k: v for k, v in identity_data.items() if v is not None and v != []
+        }
+        return (
+            {"service_identity": filtered_identity_data}
+            if filtered_identity_data
+            else {}
+        )
+
+    def _serialize_connection_info(self, conn_info: Any) -> Dict[str, Any]:
+        """Serializes a connection_config_info object into a dictionary for 
YAML export."""
+        if not conn_info:
+            return {}
+        conn_type_str = conn_info.connection_type.lower().replace("_", "-")
+        conn_data = {
+            "type": conn_type_str,
+            "uri": conn_info.uri,
+        }
+        if conn_type_str == CatalogConnectionType.HADOOP.value:
+            conn_data["warehouse"] = conn_info.warehouse
+        elif conn_type_str == CatalogConnectionType.HIVE.value:
+            conn_data["warehouse"] = conn_info.warehouse
+        elif conn_type_str == CatalogConnectionType.ICEBERG.value:
+            if (
+                hasattr(conn_info, "remote_catalog_name")
+                and conn_info.remote_catalog_name
+            ):
+                conn_data["remote_catalog_name"] = 
conn_info.remote_catalog_name
+        if (
+            hasattr(conn_info, "authentication_parameters")
+            and conn_info.authentication_parameters
+        ):
+            conn_data.update(
+                
self._serialize_authentication_info(conn_info.authentication_parameters)
+            )
+        if hasattr(conn_info, "service_identity") and 
conn_info.service_identity:
+            conn_data.update(
+                
self._serialize_service_identity_info(conn_info.service_identity)
+            )
+        # Filtered out None or empty values for cleaner YAML output
+        filtered_conn_data = {
+            k: v for k, v in conn_data.items() if v is not None and v != []
+        }
+        return {"connection": filtered_conn_data} if filtered_conn_data else {}
+
+    def _export_catalogs(self, api: PolarisDefaultApi) -> List[Dict[str, Any]]:
+        """Exports all catalogs and their nested entities into a list of 
dictionaries."""
+        catalogs_list = []
+        try:
+            catalogs = sorted(api.list_catalogs().catalogs, key=lambda c: 
c.name)
+            for c_summary in catalogs:
+                c = api.get_catalog(c_summary.name)
+                storage_type = c.storage_config_info.storage_type
+                catalog_info = {
+                    "name": c.name,
+                    "type": c.type.lower() if c.type else "internal",
+                    "storage_type": storage_type.lower(),
+                    "default_base_location": 
c.properties.default_base_location,
+                    "allowed_locations": sorted(
+                        c.storage_config_info.allowed_locations
+                    ),
+                    "properties": c.properties.additional_properties
+                    if c.properties.additional_properties
+                    else {},
+                }
+                # Serialize storage config details
+                if storage_type.lower() == StorageType.S3.value:
+                    s3_info = {
+                        "role_arn": c.storage_config_info.role_arn,
+                        "external_id": c.storage_config_info.external_id,
+                        "user_arn": c.storage_config_info.user_arn,
+                        "region": c.storage_config_info.region,
+                        "endpoint": c.storage_config_info.endpoint,
+                        "sts_unavailable": 
c.storage_config_info.sts_unavailable,
+                        "kms_unavailable": 
c.storage_config_info.kms_unavailable,
+                        "path_style_access": 
c.storage_config_info.path_style_access,
+                        "current_kms_key": 
c.storage_config_info.current_kms_key,
+                        "allowed_kms_keys": sorted(
+                            c.storage_config_info.allowed_kms_keys
+                        )
+                        if hasattr(c.storage_config_info, "allowed_kms_keys")
+                        and c.storage_config_info.allowed_kms_keys
+                        else [],
+                    }
+                    catalog_info.update(
+                        {k: v for k, v in s3_info.items() if v is not None and 
v != []}
+                    )
+                elif storage_type.lower() == StorageType.AZURE.value:
+                    azure_info = {
+                        "tenant_id": c.storage_config_info.tenant_id,
+                        "multi_tenant_app_name": 
c.storage_config_info.multi_tenant_app_name,
+                        "consent_url": c.storage_config_info.consent_url,
+                    }
+                    catalog_info.update(
+                        {
+                            k: v
+                            for k, v in azure_info.items()
+                            if v is not None and v != []
+                        }
+                    )
+                elif storage_type.lower() == StorageType.GCS.value:
+                    gcs_info = {
+                        "service_account": 
c.storage_config_info.gcs_service_account,
+                    }
+                    catalog_info.update(
+                        {k: v for k, v in gcs_info.items() if v is not None 
and v != []}
+                    )
+                # Serialize connection info for external catalogs
+                if c.type.lower() == CatalogType.EXTERNAL.value:
+                    catalog_info.update(
+                        
self._serialize_connection_info(c.connection_config_info)
+                    )
+                # Add nested entities
+                catalog_info["roles"] = self._export_catalog_roles_for_catalog(
+                    api, c.name
+                )
+                catalog_info["namespaces"] = 
self._export_namespaces_for_catalog(
+                    api, c.name
+                )
+                catalog_info["policies"] = self._export_policies_for_catalog(
+                    api, c.name
+                )
+                # remove empty sections
+                if not catalog_info.get("roles"):
+                    del catalog_info["roles"]
+                if not catalog_info.get("namespaces"):
+                    del catalog_info["namespaces"]
+                if not catalog_info.get("policies"):
+                    del catalog_info["policies"]
+                if not catalog_info.get("properties"):
+                    del catalog_info["properties"]
+                catalogs_list.append(catalog_info)
+        except Exception:
+            logger.exception("Failed to export catalogs")
+        return catalogs_list
+
+    def _export_catalog_roles_for_catalog(
+        self, api: PolarisDefaultApi, catalog_name: str
+    ) -> Dict[str, Any]:
+        """Export all catalog roles for a given catalog, including assignments 
and privileges."""
+        roles_map: Dict[str, Any] = {}
+        try:
+            roles = sorted(
+                api.list_catalog_roles(catalog_name).roles, key=lambda r: 
r.name
+            )
+            for r in roles:
+                role_info: Dict[str, Any] = {}
+                # Assignments
+                assigned_roles_resp = (
+                    api.list_assignee_principal_roles_for_catalog_role(
+                        catalog_name, r.name
+                    )
+                )
+                if assigned_roles_resp and assigned_roles_resp.roles:
+                    role_info["assign_to"] = sorted(
+                        [role.name for role in assigned_roles_resp.roles]
+                    )
+                # Privileges
+                privs = api.list_grants_for_catalog_role(catalog_name, 
r.name).grants
+                if privs:
+                    privileges: Dict[str, Any] = {}
+                    catalog_privs = [
+                        p.privilege.value for p in privs if p.type.lower() == 
"catalog"
+                    ]
+                    if catalog_privs:
+                        privileges["catalog"] = sorted(catalog_privs)
+                    ns_privs: Dict[str, List[str]] = {}
+                    for p in privs:
+                        if p.type.lower() == "namespace":
+                            ns_name = ".".join(p.namespace)
+                            if ns_name not in ns_privs:
+                                ns_privs[ns_name] = []
+                            ns_privs[ns_name].append(p.privilege.value)
+                    for ns_name in ns_privs:
+                        ns_privs[ns_name] = sorted(ns_privs[ns_name])
+                    if ns_privs:
+                        privileges["namespace"] = ns_privs
+                    if privileges:
+                        role_info["privileges"] = privileges
+                roles_map[r.name] = role_info
+        except Exception:
+            logger.exception(
+                f"Failed to export catalog roles for catalog '{catalog_name}'"
+            )
+        return roles_map
+
+    def _export_namespaces_for_catalog(
+        self, api: PolarisDefaultApi, catalog_name: str
+    ) -> List[Any]:
+        """Export all namespaces for a given catalog."""
+        namespaces_list: List[Any] = []
+        catalog_api_client = self._get_catalog_api(api)
+        catalog_api = IcebergCatalogAPI(catalog_api_client)
+        try:
+            namespaces = sorted(
+                catalog_api.list_namespaces(prefix=catalog_name).namespaces
+            )
+            for ns in namespaces:
+                ns_name = ".".join(ns)
+                ns_details = catalog_api.load_namespace_metadata(
+                    prefix=catalog_name, namespace=UNIT_SEPARATOR.join(ns)
+                )
+                if hasattr(ns_details, "location") or hasattr(ns_details, 
"properties"):
+                    ns_info = {"name": ns_name}
+                    if hasattr(ns_details, "location") and ns_details.location:
+                        ns_info["location"] = ns_details.location
+                    if hasattr(ns_details, "properties") and 
ns_details.properties:
+                        ns_info["properties"] = ns_details.properties
+                    namespaces_list.append(ns_info)
+                else:
+                    namespaces_list.append(ns_name)
+        except Exception:
+            logger.exception(
+                f"Failed to export namespaces for catalog '{catalog_name}'"
+            )
+        return namespaces_list
+
+    def _export_policies_for_catalog(
+        self, api: PolarisDefaultApi, catalog_name: str
+    ) -> Dict[str, Any]:
+        """Export all policies for a given catalog."""
+        policies_map: Dict[str, Any] = {}
+        catalog_api_client = self._get_catalog_api(api)
+        catalog_api = IcebergCatalogAPI(catalog_api_client)
+        try:
+            policy_api = PolicyAPI(catalog_api_client)
+            namespaces = sorted(
+                catalog_api.list_namespaces(prefix=catalog_name).namespaces
+            )
+            for ns in namespaces:
+                ns_name = ".".join(ns)
+                namespace_str = ns_name.replace(".", UNIT_SEPARATOR)
+                policies = policy_api.list_policies(
+                    prefix=catalog_name, namespace=namespace_str
+                ).identifiers
+                for p in policies:
+                    # Load the full policy to get its content
+                    full_policy = policy_api.load_policy(
+                        prefix=catalog_name, namespace=namespace_str, 
policy_name=p.name
+                    )
+                    policy_obj = full_policy.policy
+                    # Compact the content JSON string into a single line for 
export
+                    compact_content = None
+                    if policy_obj.content:
+                        try:
+                            content_dict = json.loads(policy_obj.content)
+                            compact_content = json.dumps(
+                                content_dict, separators=(",", ":")
+                            )
+                        except json.JSONDecodeError:
+                            compact_content = policy_obj.content
+                    policy_info = {
+                        "namespace": ns_name,
+                        "type": policy_obj.policy_type,
+                        "content": compact_content,
+                    }
+                    if policy_obj.description:
+                        policy_info["description"] = policy_obj.description
+                    policies_map[p.name] = policy_info
+        except Exception:
+            logger.exception(f"Failed to export policies for catalog 
'{catalog_name}'")
+        return policies_map
+
+    def _load_setup_config(self) -> Dict[str, Any]:
+        """Load and cache the setup configuration from a YAML file."""
+        if self.setup_config is None:
+            raise ValueError(
+                "Setup config file path is not provided for apply command."
+            )
+        if self._config_cache is None:
+            if not os.path.isfile(self.setup_config):
+                raise FileNotFoundError(f"Config file not found: 
{self.setup_config}")
+            try:
+                with open(self.setup_config, "r") as file:
+                    self._config_cache = yaml.safe_load(file)
+                    if not isinstance(self._config_cache, dict):
+                        raise ValueError(
+                            "Invalid configuration format: Expected a 
dictionary at the root level."
+                        )
+            except (yaml.YAMLError, ValueError) as e:
+                raise ValueError(f"Failed to parse YAML configuration: {e}")
+        return self._config_cache
+
+    def validate(self) -> None:
+        """Validate the command arguments."""
+        if self.setup_subcommand == Subcommands.APPLY and self.setup_config is 
None:
+            raise Exception("`setup_config` is required for the `apply` 
subcommand")
+
+    def execute(self, api: PolarisDefaultApi) -> None:
+        """Execute the setup command based on the subcommand (apply or 
export)."""
+        if self.setup_subcommand == Subcommands.APPLY:
+            if self.dry_run:
+                logger.info("=== Starting Setup Dry-Run ===")
+            else:
+                logger.info("=== Starting Setup Apply Process ===")
+            config = self._load_setup_config()
+            # Process global entities
+            self._create_principal_roles(
+                api, config.get("principal_roles", []), dry_run=self.dry_run
+            )
+            self._create_principals(
+                api, config.get("principals", {}), dry_run=self.dry_run
+            )
+            # Process entities per catalog
+            for catalog_data in config.get("catalogs", []):
+                catalog_name = catalog_data.get("name")
+                if not catalog_name:
+                    logger.warning("Skipping catalog with no name.")
+                    continue
+                logger.info(f"--- Processing catalog: {catalog_name} ---")
+                # Create the catalog
+                success = self._create_catalogs(
+                    api, [catalog_data], dry_run=self.dry_run
+                )
+                if not success and not self.dry_run:
+                    logger.warning(
+                        f"Skipping further processing for catalog 
'{catalog_name}' due to creation failure."
+                    )
+                    continue
+                # Create catalog specific entities
+                self._create_namespaces(
+                    api,
+                    catalog_name,
+                    catalog_data.get("namespaces", []),
+                    dry_run=self.dry_run,
+                )
+                self._create_catalog_roles(
+                    api,
+                    catalog_name,
+                    catalog_data.get("roles", {}),
+                    dry_run=self.dry_run,
+                )
+                self._create_policies_and_attachments(
+                    api,
+                    catalog_name,
+                    catalog_data.get("policies", {}),
+                    dry_run=self.dry_run,
+                )
+                logger.info(f"--- Finished processing catalog: {catalog_name} 
---")
+            if self.dry_run:
+                logger.info("=== Dry-Run Finished ===")
+            else:
+                logger.info("=== Setup Apply Process Completed Successfully 
===")
+        elif self.setup_subcommand == Subcommands.EXPORT:
+            self._export_config(api)
+        else:
+            raise Exception(f"Unsupported subcommand: {self.setup_subcommand}")
+
+    def _log_dry_run(
+        self,
+        action: str,
+        entity_type: str,
+        entity_name: str,
+        details: Optional[Dict[str, Any]] = None,
+    ) -> None:
+        """Helper for logging dry-run actions."""
+        message = f"DRY-RUN: Would {action} {entity_type} {entity_name}"
+        if details:
+            details_copy = details.copy()
+            # Clean up empty fields for cleaner output
+            for key in [k for k, v in details_copy.items() if not v and v is 
not False]:
+                if key in details_copy:
+                    del details_copy[key]
+            if details_copy:
+                details_str = yaml.dump(details_copy, indent=2, 
sort_keys=False).strip()
+                message += f" with details:\n{details_str}"
+        logger.info(message)
+
+    def _create_principals(
+        self,
+        api: PolarisDefaultApi,
+        principals_config: Dict[str, Any],
+        dry_run: bool = False,
+    ) -> None:
+        """Create principals and assign them to principal roles."""
+        logger.info("--- Processing principals ---")
+        existing_principals = self._get_existing_principals(api)
+        for principal_name, principal_data in principals_config.items():
+            if principal_name in existing_principals:
+                logger.info(
+                    f"Skipping creation for already existing principal: 
'{principal_name}'"
+                )
+            else:
+                # Create principal
+                if dry_run:
+                    self._log_dry_run(
+                        "create", "principal", principal_name, principal_data
+                    )
+                    existing_principals.add(principal_name)
+                else:
+                    try:
+                        logger.info(f"Creating principal: {principal_name}")
+                        principal_cmd = PrincipalsCommand(
+                            principals_subcommand=Subcommands.CREATE,
+                            principal_name=principal_name,
+                            type=principal_data.get(
+                                "type", PrincipalType.SERVICE.value
+                            ).lower(),
+                            properties=principal_data.get("properties", {}),
+                            client_id="",
+                            principal_role="",
+                            set_properties={},
+                            remove_properties=[],
+                        )
+                        principal_cmd.validate()
+                        principal_cmd.execute(api)
+                        logger.info(
+                            f"Principal '{principal_name}' created 
successfully."
+                        )
+                        existing_principals.add(principal_name)
+                    except Exception:
+                        logger.exception(
+                            f"Failed to create principal '{principal_name}'"
+                        )
+            # Assign roles
+            roles_to_assign = principal_data.get("roles", [])
+            if not roles_to_assign:
+                continue
+
+            self._get_existing_principal_roles(api)
+            for role_name in roles_to_assign:
+                if (
+                    self._existing_principal_roles is None
+                    or role_name not in self._existing_principal_roles
+                ):
+                    logger.warning(
+                        f"Skipping assignment for non-existing principal role 
'{role_name}' for principal '{principal_name}'"
+                    )
+                    continue
+
+                if dry_run:
+                    self._log_dry_run(
+                        "assign",
+                        "principal",
+                        f"'{principal_name}' to role '{role_name}'",
+                    )
+                else:
+                    try:
+                        logger.info(
+                            f"Assigning principal '{principal_name}' to role 
'{role_name}'"
+                        )
+                        role_cmd = PrincipalRolesCommand(
+                            principal_roles_subcommand=Subcommands.GRANT,
+                            principal_name=principal_name,
+                            principal_role_name=role_name,
+                            catalog_name="",
+                            catalog_role_name="",
+                            properties={},
+                            set_properties={},
+                            remove_properties=[],
+                        )
+                        role_cmd.validate()
+                        role_cmd.execute(api)
+                        logger.info(
+                            f"Assigned principal '{principal_name}' to role 
'{role_name}' successfully."
+                        )
+                    except Exception:
+                        logger.exception(
+                            f"Failed to assign principal '{principal_name}' to 
role '{role_name}'"
+                        )
+        logger.info("--- Finished processing principals ---")
+
+    def _create_principal_roles(
+        self,
+        api: PolarisDefaultApi,
+        principal_roles_config: List[str],
+        dry_run: bool = False,
+    ) -> None:
+        """Create principal roles."""
+        logger.info("--- Processing principal roles ---")
+        self._get_existing_principal_roles(api)
+
+        for role_name in principal_roles_config:
+            if (
+                self._existing_principal_roles is not None
+                and role_name in self._existing_principal_roles
+            ):
+                logger.info(
+                    f"Skipping creation for already existing principal role: 
'{role_name}'"
+                )
+                continue
+            if dry_run:
+                self._log_dry_run("create", "principal role", role_name)
+                if self._existing_principal_roles is not None:
+                    self._existing_principal_roles.add(role_name)
+            else:
+                try:
+                    logger.info(f"Creating principal role: {role_name}")
+                    cmd = PrincipalRolesCommand(
+                        principal_roles_subcommand=Subcommands.CREATE,
+                        principal_role_name=role_name,
+                        principal_name="",
+                        catalog_name="",
+                        catalog_role_name="",
+                        properties={},
+                        set_properties={},
+                        remove_properties=[],
+                    )
+                    cmd.validate()
+                    cmd.execute(api)
+                    logger.info(f"Principal role '{role_name}' created 
successfully.")
+                    if self._existing_principal_roles is not None:
+                        self._existing_principal_roles.add(role_name)
+                except Exception:
+                    logger.exception(f"Failed to create principal role 
'{role_name}'")
+        logger.info("--- Finished processing principal roles ---")
+
+    def _map_storage_properties(self, catalog_data: Dict[str, Any]) -> 
Dict[str, Any]:
+        """Maps storage-related properties from YAML data to command 
arguments."""
+        storage_keys = [
+            "storage_type",
+            "default_base_location",
+            "allowed_locations",
+            "properties",
+            "role_arn",
+            "external_id",
+            "user_arn",
+            "region",
+            "tenant_id",
+            "multi_tenant_app_name",
+            "consent_url",
+            "service_account",
+            "endpoint",
+            "endpoint_internal",
+            "sts_endpoint",
+            "sts_unavailable",
+            "kms_unavailable",
+            "path_style_access",
+            "current_kms_key",
+            "allowed_kms_keys",
+        ]
+        return {
+            key: catalog_data.get(key)
+            for key in storage_keys
+            if catalog_data.get(key) is not None
+        }
+
+    def _map_authentication_properties(
+        self, auth_data: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Maps authentication properties from YAML data to command 
arguments."""
+        auth_args = {}
+        auth_type = auth_data.get("type")
+        if not auth_type:
+            return {}
+        auth_args["catalog_authentication_type"] = auth_type
+        if auth_type == AuthenticationType.OAUTH.value:
+            auth_args.update(
+                {
+                    "catalog_token_uri": auth_data.get("token_uri"),
+                    "catalog_client_id": auth_data.get("client_id"),
+                    "catalog_client_secret": auth_data.get("client_secret"),
+                    "catalog_client_scopes": auth_data.get("scopes"),
+                }
+            )
+        elif auth_type == AuthenticationType.BEARER.value:
+            auth_args["catalog_bearer_token"] = auth_data.get("token")
+        elif auth_type == AuthenticationType.SIGV4.value:
+            auth_args.update(
+                {
+                    "catalog_role_arn": auth_data.get("role_arn"),
+                    "catalog_role_session_name": 
auth_data.get("role_session_name"),
+                    "catalog_external_id": auth_data.get("external_id"),
+                    "catalog_signing_region": auth_data.get("signing_region"),
+                    "catalog_signing_name": auth_data.get("signing_name"),
+                }
+            )
+        return auth_args
+
+    def _map_service_identity_properties(
+        self, identity_data: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Maps service identity properties from YAML data to command 
arguments."""
+        identity_args = {}
+        identity_type = identity_data.get("type")
+        if not identity_type:
+            return {}
+        identity_args["catalog_service_identity_type"] = identity_type
+        if identity_type == ServiceIdentityType.AWS_IAM.value:
+            identity_args["catalog_service_identity_iam_arn"] = 
identity_data.get(
+                "iam_arn"
+            )
+        return identity_args
+
+    def _map_connection_properties(
+        self, catalog_data: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Maps connection specific properties from YAML data to command 
arguments."""
+        connection_args = {}
+        connection_data = catalog_data.get("connection", {})
+        if not connection_data:
+            return {}
+        conn_type = connection_data.get("type")
+        if not conn_type:
+            return {}
+        connection_args["catalog_connection_type"] = conn_type
+        connection_args["catalog_uri"] = connection_data.get("uri")
+        # Connection specific properties
+        if conn_type == CatalogConnectionType.HADOOP.value:
+            connection_args["hadoop_warehouse"] = 
connection_data.get("warehouse")
+        elif conn_type == CatalogConnectionType.HIVE.value:
+            connection_args["hive_warehouse"] = 
connection_data.get("warehouse")
+        elif conn_type == CatalogConnectionType.ICEBERG.value:
+            connection_args["iceberg_remote_catalog_name"] = 
connection_data.get(
+                "remote_catalog_name"
+            )
+        # Authentication and Service Identity
+        connection_args.update(
+            self._map_authentication_properties(
+                connection_data.get("authentication", {})
+            )
+        )
+        connection_args.update(
+            self._map_service_identity_properties(
+                connection_data.get("service_identity", {})
+            )
+        )
+        return connection_args
+
+    def _create_catalogs(
+        self,
+        api: PolarisDefaultApi,
+        catalogs_config: List[Dict[str, Any]],
+        dry_run: bool = False,
+    ) -> bool:
+        """Create catalogs by parsing YAML data and calling the 
CatalogsCommand.
+        Returns True if all catalogs were processed successfully or already 
exist.
+        """
+        logger.info("--- Processing catalogs ---")
+        overall_success = True
+        existing_catalogs = self._get_existing_catalogs(api)
+        for catalog_data in catalogs_config:
+            catalog_name = catalog_data.get("name")
+            if not catalog_name:
+                logger.warning("Skipping catalog with no name.")
+                continue
+            if catalog_name in existing_catalogs:
+                logger.info(
+                    f"Skipping creation for already existing catalog: 
'{catalog_name}'"
+                )
+                continue
+            if dry_run:
+                details = catalog_data.copy()
+                if "name" in details:
+                    del details["name"]
+                self._log_dry_run("create", "catalog", catalog_name, details)
+                existing_catalogs.add(catalog_name)
+            else:
+                try:
+                    logger.info(f"Creating catalog: {catalog_name}")
+                    # Map YAML data to command arguments using helpers
+                    command_args = {
+                        "catalogs_subcommand": Subcommands.CREATE,
+                        "catalog_name": catalog_name,
+                        "catalog_type": catalog_data.get(
+                            "type", CatalogType.INTERNAL.value
+                        ).lower(),
+                    }
+                    
command_args.update(self._map_storage_properties(catalog_data))
+                    if command_args.get("catalog_type") == 
CatalogType.EXTERNAL.value:
+                        conn_args = 
self._map_connection_properties(catalog_data)
+                        if not conn_args:
+                            logger.warning(
+                                f"External catalog '{catalog_name}' is missing 
the required 'connection' info block."
+                            )
+                            overall_success = False
+                            continue
+                        command_args.update(conn_args)
+                    # Explicitly call constructor to satisfy mypy and improve 
readability
+                    cmd = CatalogsCommand(
+                        catalogs_subcommand=command_args.get(
+                            "catalogs_subcommand", Subcommands.CREATE
+                        ),
+                        catalog_name=command_args.get("catalog_name", ""),
+                        catalog_type=command_args.get("catalog_type", 
"INTERNAL"),
+                        default_base_location=command_args.get(
+                            "default_base_location", ""
+                        ),
+                        storage_type=command_args.get("storage_type", ""),
+                        
allowed_locations=command_args.get("allowed_locations") or [],
+                        properties=command_args.get("properties") or {},
+                        set_properties=command_args.get("set_properties") or 
{},
+                        
remove_properties=command_args.get("remove_properties") or [],
+                        role_arn=command_args.get("role_arn", ""),
+                        external_id=command_args.get("external_id", ""),
+                        user_arn=command_args.get("user_arn", ""),
+                        region=command_args.get("region", ""),
+                        tenant_id=command_args.get("tenant_id", ""),
+                        multi_tenant_app_name=command_args.get(
+                            "multi_tenant_app_name", ""
+                        ),
+                        consent_url=command_args.get("consent_url", ""),
+                        service_account=command_args.get("service_account", 
""),
+                        hierarchical=command_args.get("hierarchical", False),
+                        endpoint=command_args.get("endpoint", ""),
+                        
endpoint_internal=command_args.get("endpoint_internal", ""),
+                        sts_endpoint=command_args.get("sts_endpoint", ""),
+                        sts_unavailable=command_args.get("sts_unavailable", 
False),
+                        kms_unavailable=command_args.get("kms_unavailable", 
False),
+                        
path_style_access=command_args.get("path_style_access", False),
+                        current_kms_key=command_args.get("current_kms_key", 
""),
+                        allowed_kms_keys=command_args.get("allowed_kms_keys") 
or [],
+                        catalog_connection_type=command_args.get(
+                            "catalog_connection_type", ""
+                        ),
+                        catalog_uri=command_args.get("catalog_uri", ""),
+                        hadoop_warehouse=command_args.get("hadoop_warehouse", 
""),
+                        hive_warehouse=command_args.get("hive_warehouse", ""),
+                        iceberg_remote_catalog_name=command_args.get(
+                            "iceberg_remote_catalog_name", ""
+                        ),
+                        catalog_authentication_type=command_args.get(
+                            "catalog_authentication_type", ""
+                        ),
+                        
catalog_token_uri=command_args.get("catalog_token_uri", ""),
+                        
catalog_client_id=command_args.get("catalog_client_id", ""),
+                        catalog_client_secret=command_args.get(
+                            "catalog_client_secret", ""
+                        ),
+                        
catalog_client_scopes=command_args.get("catalog_client_scopes")
+                        or [],
+                        catalog_bearer_token=command_args.get(
+                            "catalog_bearer_token", ""
+                        ),
+                        catalog_role_arn=command_args.get("catalog_role_arn", 
""),
+                        catalog_role_session_name=command_args.get(
+                            "catalog_role_session_name", ""
+                        ),
+                        
catalog_external_id=command_args.get("catalog_external_id", ""),
+                        catalog_signing_region=command_args.get(
+                            "catalog_signing_region", ""
+                        ),
+                        catalog_signing_name=command_args.get(
+                            "catalog_signing_name", ""
+                        ),
+                        catalog_service_identity_type=command_args.get(
+                            "catalog_service_identity_type", ""
+                        ),
+                        catalog_service_identity_iam_arn=command_args.get(
+                            "catalog_service_identity_iam_arn", ""
+                        ),
+                    )
+                    cmd.validate()
+                    cmd.execute(api)
+                    logger.info(f"Catalog '{catalog_name}' created 
successfully.")
+                    existing_catalogs.add(catalog_name)
+                except Exception:
+                    logger.exception(f"Failed to create catalog 
'{catalog_name}'")
+                    overall_success = False
+        logger.info("--- Finished processing catalogs ---")
+        return overall_success
+
+    def _create_catalog_roles(
+        self,
+        api: PolarisDefaultApi,
+        catalog_name: str,
+        roles_config: Dict[str, Any],
+        dry_run: bool = False,
+    ) -> None:
+        """Create catalog roles, assign them to principal roles, and grant 
privileges."""
+        logger.info(f"--- Processing catalog roles for catalog: {catalog_name} 
---")
+        existing_roles_in_catalog = self._get_existing_catalog_roles(api, 
catalog_name)
+        self._get_existing_principal_roles(api)
+        for role_name, role_data in roles_config.items():
+            if role_name in existing_roles_in_catalog:
+                logger.info(
+                    f"Skipping creation for already existing catalog role: 
'{role_name}' in catalog '{catalog_name}'"
+                )
+            else:
+                if dry_run:
+                    self._log_dry_run(
+                        "create", "catalog role", role_name, {"catalog": 
catalog_name}
+                    )
+                    # Add to local cache for dry-run to prevent duplicate logs 
for assigns/grants
+                    existing_roles_in_catalog.add(role_name)
+                else:
+                    try:
+                        logger.info(
+                            f"Creating catalog role: {role_name} in catalog: 
{catalog_name}"
+                        )
+                        cmd = CatalogRolesCommand(
+                            catalog_roles_subcommand=Subcommands.CREATE,
+                            catalog_role_name=role_name,
+                            catalog_name=catalog_name,
+                            properties=role_data.get("properties", {}),
+                            principal_role_name="",
+                            set_properties={},
+                            remove_properties=[],
+                        )
+                        cmd.validate()
+                        cmd.execute(api)
+                        logger.info(
+                            f"Catalog role '{role_name}' created successfully 
in catalog '{catalog_name}'."
+                        )
+                        # Update the cache with the newly created role
+                        existing_roles_in_catalog.add(role_name)
+                    except Exception:
+                        logger.exception(
+                            f"Failed to create catalog role '{role_name}' in 
catalog '{catalog_name}'"
+                        )
+                        continue
+            # Assign to principal roles
+            principal_roles_to_assign = role_data.get("assign_to", [])
+            for principal_role_name in principal_roles_to_assign:
+                if (
+                    self._existing_principal_roles is None
+                    or principal_role_name not in 
self._existing_principal_roles
+                ):
+                    logger.warning(
+                        f"Skipping assignment of catalog role '{role_name}' to 
non-existing principal role '{principal_role_name}'"
+                    )
+                    continue
+                if dry_run:
+                    self._log_dry_run(
+                        "assign",
+                        "catalog role",
+                        f"'{role_name}' to principal role 
'{principal_role_name}'",
+                        {"catalog": catalog_name},
+                    )
+                else:
+                    try:
+                        logger.info(
+                            f"Assigning catalog role '{role_name}' to 
principal role '{principal_role_name}' in catalog '{catalog_name}'"
+                        )
+                        cmd = CatalogRolesCommand(
+                            catalog_roles_subcommand=Subcommands.GRANT,
+                            catalog_role_name=role_name,
+                            principal_role_name=principal_role_name,
+                            catalog_name=catalog_name,
+                            properties=None,
+                            set_properties={},
+                            remove_properties=[],
+                        )
+                        cmd.validate()
+                        cmd.execute(api)
+                        logger.info(
+                            f"Assigned catalog role '{role_name}' to principal 
role '{principal_role_name}' successfully."
+                        )
+                    except Exception:
+                        logger.exception(
+                            f"Failed to assign catalog role '{role_name}' to 
principal role '{principal_role_name}'"
+                        )
+            # Grant privileges
+            privileges_to_grant = role_data.get("privileges", {})
+            if "table" in privileges_to_grant or "view" in privileges_to_grant:
+                logger.warning(
+                    f"Granting 'table' or 'view' level privileges is not 
currently supported by the 'setup apply' command. "
+                    f"The defined table/view privileges for role '{role_name}' 
in catalog '{catalog_name}' will be ignored."
+                )
+            for priv in privileges_to_grant.get("catalog", []):
+                self._grant_privilege(
+                    api, catalog_name, role_name, "catalog", priv, 
dry_run=dry_run
+                )
+            for ns_name, privs in privileges_to_grant.get("namespace", 
{}).items():
+                for priv in privs:
+                    self._grant_privilege(
+                        api,
+                        catalog_name,
+                        role_name,
+                        "namespace",
+                        priv,
+                        namespace=ns_name,
+                        dry_run=dry_run,
+                    )
+        logger.info(
+            f"--- Finished processing catalog roles for catalog: 
{catalog_name} ---"
+        )
+
+    def _grant_privilege(
+        self,
+        api: PolarisDefaultApi,
+        catalog_name: str,
+        catalog_role_name: str,
+        level: str,
+        privilege: str,
+        namespace: Optional[str] = None,
+        dry_run: bool = False,
+    ) -> None:
+        """Helper to grant a single privilege."""
+        if dry_run:
+            self._log_dry_run(
+                "grant",
+                "privilege",
+                f"'{privilege}' on {level} '{namespace or catalog_name}' to 
role '{catalog_role_name}'",
+            )
+            return
+        try:
+            log_message = f"privilege '{privilege}' on {level} '{namespace or 
catalog_name}' to role '{catalog_role_name}'"
+            logger.info(f"Granting {log_message}")
+            privilege_args = {
+                "action": Actions.GRANT,
+                "cascade": False,
+                "privilege": privilege,
+                "catalog_role_name": catalog_role_name,
+                "catalog_name": catalog_name,
+            }
+            if level == "namespace":
+                if namespace:
+                    privilege_args["namespace"] = namespace.split(".")
+            subcommand_map = {
+                "catalog": Subcommands.CATALOG,
+                "namespace": Subcommands.NAMESPACE,
+            }
+            privileges_subcommand = subcommand_map.get(level)
+            if not privileges_subcommand:
+                logger.warning(f"Unsupported privilege level '{level}'")
+                return
+            cmd = PrivilegesCommand(
+                privileges_subcommand=privileges_subcommand,
+                action=str(privilege_args.get("action")),
+                cascade=bool(privilege_args.get("cascade")),
+                privilege=str(privilege_args.get("privilege")),
+                catalog_role_name=str(privilege_args.get("catalog_role_name")),
+                catalog_name=str(privilege_args.get("catalog_name")),
+                namespace=cast(List[str], privilege_args.get("namespace") or 
[]),
+                view="",
+                table="",
+            )
+            cmd.validate()
+            cmd.execute(api)
+            logger.info(f"Successfully granted {log_message}")
+        except Exception:
+            logger.exception(f"Failed to grant {log_message}")
+
+    def _create_namespaces(
+        self,
+        api: PolarisDefaultApi,
+        catalog_name: str,
+        namespaces_config: List[Any],
+        dry_run: bool = False,
+    ) -> None:
+        """Create namespaces in a specific catalog, including any parent 
namespaces."""
+        logger.info(f"--- Processing namespaces for catalog: {catalog_name} 
---")
+        catalog_api_client = self._get_catalog_api(api)
+        catalog_api = IcebergCatalogAPI(catalog_api_client)
+        try:
+            existing_namespaces = {
+                ".".join(ns)
+                for ns in 
catalog_api.list_namespaces(prefix=catalog_name).namespaces
+            }
+        except NotFoundException:
+            # This is expected if the catalog has no namespaces yet
+            existing_namespaces = set()
+        except Exception:
+            logger.exception(f"Failed to fetch namespaces for catalog 
'{catalog_name}'")
+            existing_namespaces = set()
+        all_namespaces_to_create = set()
+        namespace_data_map = {}
+        for ns_item in namespaces_config:
+            ns_name: Optional[str]
+            ns_data: Dict[str, Any]
+            if isinstance(ns_item, str):
+                ns_name = ns_item
+                ns_data = {}
+            elif isinstance(ns_item, dict):
+                ns_name = ns_item.get("name")
+                ns_data = ns_item
+            else:
+                logger.warning(f"Skipping invalid namespace entry: {ns_item}")
+                continue
+            if not ns_name:
+                logger.warning(
+                    f"Skipping namespace with no name in catalog 
'{catalog_name}'"
+                )
+                continue
+            # Store data for the full namespace path
+            namespace_data_map[ns_name] = ns_data
+            # Add the full namespace and all its parents to the set
+            parts = ns_name.split(".")
+            for i in range(len(parts)):
+                parent_ns_name = ".".join(parts[: i + 1])
+                all_namespaces_to_create.add(parent_ns_name)
+        sorted_namespaces = sorted(list(all_namespaces_to_create))
+        for ns_name in sorted_namespaces:
+            if ns_name in existing_namespaces:
+                logger.info(
+                    f"Skipping creation for already existing namespace 
'{ns_name}' in catalog '{catalog_name}'"
+                )
+                continue
+            # Get data if it exists for this specific namespace, otherwise 
empty
+            ns_data = namespace_data_map.get(ns_name, {})
+            if dry_run:
+                self._log_dry_run(
+                    "create", "namespace", ns_name, {"catalog": catalog_name, 
**ns_data}
+                )
+            else:
+                try:
+                    logger.info(
+                        f"Creating namespace: '{ns_name}' in catalog: 
'{catalog_name}'"
+                    )
+                    cmd = NamespacesCommand(
+                        namespaces_subcommand=Subcommands.CREATE,
+                        catalog=catalog_name,
+                        namespace=ns_name.split("."),
+                        parent=[],
+                        location=ns_data.get("location") or "",
+                        properties=ns_data.get("properties", {}),
+                    )
+                    cmd.validate()
+                    cmd.execute(api)
+                    logger.info(
+                        f"Namespace '{ns_name}' created successfully in 
catalog '{catalog_name}'."
+                    )
+                except Exception:
+                    logger.exception(
+                        f"Failed to create namespace '{ns_name}' in catalog 
'{catalog_name}'"
+                    )
+        logger.info(
+            f"--- Finished processing namespaces for catalog: {catalog_name} 
---"
+        )
+
+    def _create_policies_and_attachments(
+        self,
+        api: PolarisDefaultApi,
+        catalog_name: str,
+        policies_config: Dict[str, Any],
+        dry_run: bool = False,
+    ) -> None:
+        """Create policies and attach them."""
+        logger.info(f"--- Processing policies for catalog: {catalog_name} ---")
+        catalog_api_client = self._get_catalog_api(api)
+        policy_api = PolicyAPI(catalog_api_client)
+        for policy_name, policy_data in policies_config.items():
+            ns_name = policy_data.get("namespace")
+            if not ns_name:
+                logger.warning(
+                    f"Skipping policy '{policy_name}' due to missing 
namespace."
+                )
+                continue
+            # Check if policy exists
+            policy_exists = False
+            try:
+                namespace_str = ns_name.replace(".", UNIT_SEPARATOR)
+                policy_api.load_policy(
+                    prefix=catalog_name,
+                    namespace=namespace_str,
+                    policy_name=policy_name,
+                )
+                policy_exists = True
+            except NotFoundException:
+                policy_exists = False
+            except Exception:
+                logger.warning(
+                    f"Could not verify existence of policy '{policy_name}', 
attempting creation."
+                )
+                policy_exists = False
+            if policy_exists:
+                logger.info(
+                    f"Skipping creation for already existing policy 
'{policy_name}' in catalog '{catalog_name}' and namespace '{ns_name}'"
+                )
+            else:
+                if not self._validate_entity(
+                    policy_data, ["type"], f"policy '{policy_name}'"
+                ):
+                    continue
+                if "content" not in policy_data and "file" not in policy_data:
+                    logger.warning(
+                        f"Skipping policy '{policy_name}' due to missing 
'content' or 'file' field."
+                    )
+                    continue
+                if dry_run:
+                    self._log_dry_run(
+                        "create",
+                        "policy",
+                        policy_name,
+                        {"catalog": catalog_name, **policy_data},
+                    )
+                else:
+                    try:
+                        logger.info(
+                            f"Creating policy: '{policy_name}' in catalog: 
'{catalog_name}' and namespace: '{ns_name}'"
+                        )
+                        policy_content_str = ""
+                        if (
+                            "content" in policy_data
+                            and policy_data["content"] is not None
+                        ):
+                            policy_content_str = 
json.dumps(policy_data["content"])
+                        elif "file" in policy_data:
+                            if self.setup_config is None:
+                                logger.error(
+                                    "Path to setup config file not available 
to load policy file"
+                                )
+                                continue
+                            config_dir = os.path.dirname(self.setup_config)
+                            policy_file_path = os.path.join(
+                                config_dir, policy_data["file"]
+                            )
+                            with open(policy_file_path, "r") as f:
+                                policy_content_str = json.dumps(json.load(f))
+                        policy_api.create_policy(
+                            prefix=catalog_name,
+                            namespace=ns_name.replace(".", UNIT_SEPARATOR),
+                            create_policy_request=CreatePolicyRequest(
+                                name=policy_name,
+                                type=policy_data["type"],
+                                description=policy_data.get("description"),
+                                content=policy_content_str,
+                            ),
+                        )
+                        logger.info(f"Policy '{policy_name}' created 
successfully.")
+                    except Exception:
+                        logger.exception(f"Failed to create policy 
'{policy_name}'")
+                        continue
+            # Attachments
+            attachments = policy_data.get("attach", [])
+            for attachment in attachments:
+                attachment_type = attachment.get("type")
+                if not attachment_type:
+                    logger.warning(
+                        f"Skipping attachment for policy '{policy_name}' due 
to missing 'type' field."
+                    )
+                    continue
+                attachment_path = attachment.get("path")
+                if dry_run:
+                    self._log_dry_run(
+                        "attach",
+                        "policy",
+                        f"'{policy_name}' to '{attachment_type}'",
+                        {"path": attachment_path},
+                    )
+                else:
+                    try:
+                        log_message = (
+                            f"Attaching policy '{policy_name}' to 
'{attachment_type}'"
+                        )
+                        if attachment_path:
+                            log_message += f" at path '{attachment_path}'"
+                        logger.info(log_message)
+                        cmd = PoliciesCommand(
+                            policies_subcommand=Subcommands.ATTACH,
+                            catalog_name=catalog_name,
+                            namespace=ns_name,
+                            policy_name=policy_name,
+                            attachment_type=attachment_type,
+                            attachment_path=attachment_path,
+                            parameters=attachment.get("parameters"),
+                            policy_file="",
+                            policy_type="",
+                            policy_description="",
+                            target_name="",
+                            detach_all=False,
+                            applicable=False,
+                        )
+                        cmd.validate()
+                        cmd.execute(api)
+                        logger.info(f"Successfully attached policy 
'{policy_name}'")
+                    except Exception:
+                        logger.exception(f"Failed to attach policy 
'{policy_name}'")
+        logger.info(f"--- Finished processing policies for catalog: 
{catalog_name} ---")
+
+    def _validate_entity(
+        self, entity: Dict[str, Any], required_fields: List[str], entity_name: 
str
+    ) -> bool:
+        """Validate that an entity has all required fields."""
+        missing_fields = [field for field in required_fields if not 
entity.get(field)]
+        if missing_fields:
+            logger.warning(
+                f"Skipping {entity_name} due to missing required fields: {', 
'.join(missing_fields)}. Entity: {entity}"
+            )
+            return False
+        return True
diff --git a/client/python/apache_polaris/cli/constants.py 
b/client/python/apache_polaris/cli/constants.py
index 4a98cc51c7..60e3378e1b 100644
--- a/client/python/apache_polaris/cli/constants.py
+++ b/client/python/apache_polaris/cli/constants.py
@@ -90,6 +90,7 @@ class Commands:
     NAMESPACES = "namespaces"
     PROFILES = "profiles"
     POLICIES = "policies"
+    SETUP = "setup"
 
 
 class Subcommands:
@@ -114,6 +115,8 @@ class Subcommands:
     RESET = "reset"
     ATTACH = "attach"
     DETACH = "detach"
+    APPLY = "apply"
+    EXPORT = "export"
 
 
 class Actions:
@@ -209,6 +212,8 @@ class Arguments:
     APPLICABLE = "applicable"
     ATTACHMENT_TYPE = "attachment_type"
     ATTACHMENT_PATH = "attachment_path"
+    SETUP_CONFIG = "setup_config"
+    DRY_RUN = "dry_run"
     REALM = "realm"
     HEADER = "header"
 
@@ -236,6 +241,9 @@ class Hints:
         " can be provided by specifying this option more than once"
     )
 
+    class Setup:
+        DRY_RUN = "If specified, the command will only print the actions to be 
taken without executing them."
+
     class Catalogs:
         GRANT = "Grant a catalog role to a catalog"
         REVOKE = "Revoke a catalog role from a catalog"
diff --git a/client/python/apache_polaris/cli/options/option_tree.py 
b/client/python/apache_polaris/cli/options/option_tree.py
index a5a4b146ce..6d8c42f044 100644
--- a/client/python/apache_polaris/cli/options/option_tree.py
+++ b/client/python/apache_polaris/cli/options/option_tree.py
@@ -901,4 +901,16 @@ class OptionTree:
                     ),
                 ],
             ),
+            Option(
+                Commands.SETUP,
+                "perform setup",
+                children=[
+                    Option(
+                        Subcommands.APPLY,
+                        args=[Argument(Arguments.DRY_RUN, bool, 
Hints.Setup.DRY_RUN)],
+                        input_name=Arguments.SETUP_CONFIG,
+                    ),
+                    Option(Subcommands.EXPORT),
+                ],
+            ),
         ]
diff --git a/client/python/pyproject.toml b/client/python/pyproject.toml
index c782448fda..e05ed4205c 100644
--- a/client/python/pyproject.toml
+++ b/client/python/pyproject.toml
@@ -38,6 +38,7 @@ dependencies = [
     "pydantic>=2.12.5,<2.13.0",
     "typing-extensions>=4.7.1",
     "boto3~=1.42.2",
+    "PyYAML>=6.0.3",
 ]
 
 [project.urls]
diff --git a/client/python/uv.lock b/client/python/uv.lock
index d5626d05ab..6d58646176 100644
--- a/client/python/uv.lock
+++ b/client/python/uv.lock
@@ -19,6 +19,7 @@ dependencies = [
     { name = "boto3" },
     { name = "pydantic" },
     { name = "python-dateutil" },
+    { name = "pyyaml" },
     { name = "typing-extensions" },
     { name = "urllib3" },
 ]
@@ -41,6 +42,7 @@ requires-dist = [
     { name = "boto3", specifier = "~=1.42.2" },
     { name = "pydantic", specifier = ">=2.12.5,<2.13.0" },
     { name = "python-dateutil", specifier = ">=2.8.2" },
+    { name = "pyyaml", specifier = ">=6.0.3" },
     { name = "typing-extensions", specifier = ">=4.7.1" },
     { name = "urllib3", specifier = ">=1.25.3,<3.0.0" },
 ]
diff --git a/site/content/guides/assets/polaris/reference-setup-config.yaml 
b/site/content/guides/assets/polaris/reference-setup-config.yaml
new file mode 100644
index 0000000000..38d7e8b3fc
--- /dev/null
+++ b/site/content/guides/assets/polaris/reference-setup-config.yaml
@@ -0,0 +1,226 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License 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.
+#
+
+# =================================================================
+#  Apache Polaris - Comprehensive Setup Configuration Sample
+#
+#  This file provides a comprehensive set of examples for all
+#  supported options in the `polaris setup apply` command.
+#  It is intended to be a reference document.
+# =================================================================
+
+# ==================================
+#        Global Entities
+# ==================================
+# Principals are identities that can be granted access, like users or services.
+principals:
+  # A principal for a quickstart user
+  quickstart_user:
+    type: service
+    roles:
+      - quickstart_user_role
+  # A principal for a readonly user
+  readonly_user:
+    type: service
+    roles:
+      - readonly_user_role
+  # A principal for a development user
+  dev_user:
+    type: service
+    roles:
+      - dev_user_role
+
+# Principal roles are global roles that group principals.
+principal_roles:
+  - quickstart_user_role
+  - readonly_user_role
+  - dev_user_role
+
+# ==================================
+#     Catalog Specific Entities
+# ==================================
+catalogs:
+  # =================================================================
+  #  Section 1: Internal Catalogs (Polaris-Managed)
+  # =================================================================
+  # Internal catalogs have their metadata managed by Polaris.
+
+  # --- Example 1.1: Internal Catalog with File Storage ---
+  - name: "quickstart_catalog"
+    type: internal
+    storage_type: "file"
+    default_base_location: "file:///var/tmp/quickstart_catalog/"
+    allowed_locations:
+      - "file:///var/tmp/quickstart_catalog/"
+    properties:
+      catalog: quickstart_catalog
+    
+    # --- Roles and Privileges ---
+    # Catalog roles are scoped to this specific catalog.
+    roles:
+      quickstart_catalog_role:
+        # Assigns this catalog role to one or more principal roles.
+        assign_to:
+          - quickstart_user_role
+        privileges:
+          # Privileges applied to the whole catalog
+          catalog:
+            - CATALOG_MANAGE_CONTENT
+      readonly_catalog_role:
+        assign_to:
+          - readonly_user_role
+        privileges:
+          catalog:
+            - TABLE_READ_DATA
+            - TABLE_FULL_METADATA
+            - VIEW_FULL_METADATA
+            - NAMESPACE_FULL_METADATA
+            - CATALOG_READ_PROPERTIES
+      dev_catalog_role:
+        assign_to: 
+          - dev_user_role
+        privileges:
+          # Privileges scoped to specific namespaces within this catalog
+          namespace:
+            dev_namespace:
+              - TABLE_READ_DATA
+              - TABLE_WRITE_DATA
+              - TABLE_FULL_METADATA
+
+    # --- Namespaces ---
+    # List of namespaces to create within this catalog.
+    namespaces:
+      - dev_namespace.sub_namespace
+
+    # --- Policies ---
+    # Defines policies that can be attached to entities in this catalog.
+    policies:
+      compaction-policy:
+        namespace: dev_namespace
+        type: "system.data-compaction"
+        description: "Policy to enable data compaction for tables"
+        content: {
+          "version": "2025-02-03",
+          "enable": true,
+          "config": {
+            "target_file_size_bytes": 134217728,
+            "compaction_strategy": "bin-pack",
+            "max-concurrent-file-group-rewrites": 5,
+            "key1": "value1"
+          }
+        }
+        ## commented out as table/view doesn't exist out of box
+        # attach:
+        #   - { type: table-like, path: dev_namespace.sub_namespace.dev_table }
+      snapshot-expiry-policy:
+        namespace: dev_namespace
+        type: "system.snapshot-expiry"
+        description: "Policy to expire old snapshots"
+        content: {
+          "version": "2025-02-03",
+          "enable": true,
+          "config": {
+            "min_snapshot_to_keep": 1,
+            "max_snapshot_age_days": 2,
+            "max_ref_age_days": 3,
+            "key1": "value1"
+          }
+        }
+        attach:
+          - { type: namespace, path: dev_namespace }
+      orphan-file-policy:
+        namespace: dev_namespace
+        type: "system.orphan-file-removal"
+        description: "Policy to remove orphan files"
+        content: {
+          "version": "2025-02-03",
+          "enable": true,
+          "max_orphan_file_age_in_days": 30,
+          "locations": ["s3://my-bucket/my-table-location"],
+          "config": {
+            "prefix_mismatch_mode": "ignore",
+            "key1": "value1"
+          }
+        }
+        attach:
+          - { type: catalog }
+
+  # --- Example 1.2: Internal Catalog with S3 Storage ---
+  - name: "quickstart_catalog_s3"
+    type: internal
+    storage_type: "s3"
+    default_base_location: "s3://quickstart-bucket/quickstart_catalog/"
+    allowed_locations:
+      - "s3://quickstart-bucket/quickstart_catalog/"
+    # --- S3-Specific Properties ---
+    # The ARN of the IAM role Polaris assumes to access S3.
+    role_arn: "arn:aws:iam::123456789012:role/QuickstartS3Role"
+    # The External ID for the IAM role assumption.
+    external_id: "12345678-1234-1234-1234-123456789abc"
+    # The ARN of the IAM user that assumes the role.
+    user_arn: "arn:aws:iam::123456789012:user/QuickstartUser"
+    # The AWS region for the S3 bucket.
+    region: "us-west-2"
+    # Custom S3 endpoint.
+    endpoint: "https://s3.us-west-2.amazonaws.com";
+    # Set to true if the storage does not support AWS STS for credential 
vending.
+    sts_unavailable: false
+    # Set to true to disable KMS key policies.
+    kms_unavailable: false
+    # Set to true to use path-style access.
+    path_style_access: false
+    # The ARN of the default KMS key for server-side encryption.
+    current_kms_key: 
"arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
+    # A list of other KMS keys that are allowed for this catalog.
+    allowed_kms_keys:
+      - 
"arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
+    properties:
+      catalog: quickstart_catalog_s3
+
+  # --- Example 1.3: Internal Catalog with Azure Storage ---
+  - name: "quickstart_catalog_azure"
+    type: internal
+    storage_type: "azure"
+    default_base_location: 
"abfss://[email protected]/quickstart_catalog/"
+    allowed_locations:
+      - 
"abfss://[email protected]/quickstart_catalog/"
+    # --- Azure-Specific Properties ---
+    # The Azure Tenant ID for the storage account.
+    tenant_id: "12345678-1234-1234-1234-123456789abc"
+    # The name of the multi-tenant app used for authentication.
+    multi_tenant_app_name: "QuickstartAzureApp"
+    # The URL for OAuth consent.
+    consent_url: "https://login.microsoftonline.com/consent";
+    properties:
+      catalog: quickstart_catalog_azure
+
+  # --- Example 1.4: Internal Catalog with GCS Storage ---
+  ## commented out as implicitly gcs auth is needed
+  # - name: "quickstart_catalog_gcs"
+  #   type: internal
+  #   storage_type: "gcs"
+  #   default_base_location: "gs://quickstart-bucket/quickstart_catalog/"
+  #   allowed_locations:
+  #     - "gs://quickstart-bucket/quickstart_catalog/"    
+  #   # --- GCS-Specific Properties ---
+  #   # The GCS service account Polaris uses to access the bucket.
+  #   service_account: 
"[email protected]"
+  #   properties:
+  #     catalog: quickstart_catalog_gcs
+
diff --git a/site/content/guides/assets/polaris/simple-setup-config.yaml 
b/site/content/guides/assets/polaris/simple-setup-config.yaml
new file mode 100644
index 0000000000..613a398096
--- /dev/null
+++ b/site/content/guides/assets/polaris/simple-setup-config.yaml
@@ -0,0 +1,48 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License 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.
+#
+
+# ==================================
+#        Global Entities
+# ==================================
+principals:
+  quickstart_user:
+    roles:
+      - quickstart_user_role
+
+principal_roles:
+  - quickstart_user_role
+
+# ==================================
+#     Catalog-Specific Entities
+# ==================================
+catalogs:
+  - name: "quickstart_catalog"
+    storage_type: "file"
+    default_base_location: "file:///var/tmp/quickstart_catalog/"
+    allowed_locations:
+      - "file:///var/tmp/quickstart_catalog/"
+    roles:
+      quickstart_catalog_role:
+        assign_to: 
+          - quickstart_user_role
+        privileges:
+          catalog:
+            - CATALOG_MANAGE_CONTENT
+    namespaces:
+      - dev_namespace
diff --git a/site/content/in-dev/unreleased/command-line-interface.md 
b/site/content/in-dev/unreleased/command-line-interface.md
index a81be15c6a..6f4b25e2f7 100644
--- a/site/content/in-dev/unreleased/command-line-interface.md
+++ b/site/content/in-dev/unreleased/command-line-interface.md
@@ -53,6 +53,7 @@ options:
 7. profiles
 8. policies
 9. repair
+10. setup
 
 Each _command_ supports several _subcommands_, and some _subcommands_ have 
_actions_ that come after the subcommand in turn. Finally, _arguments_ follow 
to form a full invocation. Within a set of named arguments at the end of an 
invocation ordering is generally not important. Many invocations also have a 
required positional argument of the type that the _command_ refers to. Again, 
the ordering of this positional argument relative to named arguments is not 
important.
 
@@ -67,6 +68,7 @@ polaris privileges namespace grant --namespace some.schema 
--catalog fourth_cata
 polaris profiles list
 polaris policies list --catalog some_catalog --namespace some.schema
 polaris repair
+polaris setup apply setup-config.yaml
 ```
 
 ### Authentication
@@ -118,6 +120,7 @@ To find details on the options that can be provided to a 
particular command or s
 polaris catalogs --help
 polaris principals create --help
 polaris profiles --help
+polaris setup --help
 ```
 
 ### catalogs
@@ -1425,6 +1428,51 @@ polaris policies update --catalog some_catalog 
--namespace some.schema --policy-
 
 The `repair` command is a bash script wrapper used to regenerate Python client 
code and update necessary dependencies, ensuring the Polaris client remains 
up-to-date and functional. **Please note that this command does not support any 
options and its usage information is not available via a `--help` flag.**
 
+### setup
+
+The `setup` command is used to automate the creation of various entities in 
Polaris, such as principals, roles, catalogs, namespaces, privileges, and 
policies, based on a configuration file. This simplifies the process of setting 
up a Polaris environment.
+
+`setup` supports the following subcommands:
+
+1. apply
+2. export
+
+#### apply
+
+The `apply` subcommand reads a configuration file and creates the specified 
entities in Polaris. The configuration file must be in YAML format and define 
the entities to be created.
+
+```
+input: polaris setup apply --help
+options:
+  apply
+    Named arguments:
+      --dry-run  If specified, the command will only print the actions to be 
taken without executing them.
+    Positional arguments:
+      setup_config
+```
+
+##### Examples
+
+```
+polaris setup apply setup-config.yaml
+```
+
+#### export
+
+The `export` subcommand retrieves the current Polaris configuration and 
outputs it in a YAML format. This output is compatible with the apply 
subcommand, allowing you to easily back up, migrate, or recreate your Polaris 
environment.
+
+```
+input: polaris setup export --help
+options:
+  export
+```
+
+##### Examples
+
+```
+polaris setup export
+```
+
 ## Examples
 
 This section outlines example code for a few common operations as well as for 
some more complex ones.

Reply via email to