blob: 72bbd5c6b20e4d3f7242256ff9c68cf4a560f08e [file]
#
# 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
from apache_polaris.cli.command import Command
from apache_polaris.cli.exceptions import CliError
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, ConflictException
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):
"""
A Command implementation to represent `polaris setup`. The instance attributes correspond to parameters
that can be provided to various subcommands, except `setup_subcommand` which represents the subcommand
itself.
Example commands:
* polaris setup apply setup-config.yaml
* polaris setup export
"""
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 CliError("`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 CliError(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", {}),
)
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,
)
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,
)
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"),
default_base_location=command_args.get("default_base_location"),
storage_type=command_args.get("storage_type"),
allowed_locations=command_args.get("allowed_locations"),
properties=command_args.get("properties"),
set_properties=command_args.get("set_properties"),
remove_properties=command_args.get("remove_properties"),
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"),
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"),
kms_unavailable=command_args.get("kms_unavailable"),
path_style_access=command_args.get("path_style_access"),
current_kms_key=command_args.get("current_kms_key"),
allowed_kms_keys=command_args.get("allowed_kms_keys"),
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"),
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"),
)
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,
)
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}")
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=Actions.GRANT,
cascade=False,
privilege=privilege,
catalog_role_name=catalog_role_name,
catalog_name=catalog_name,
namespace=namespace.split(".")
if level == "namespace" and namespace
else None,
)
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)
existing_namespaces: Set[str] = set()
listed_parents: Set[str] = set()
listed_parents.add("")
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:
parts = ns_name.split(".")
if len(parts) > 1:
parent_ns = ".".join(parts[:-1])
if (
not dry_run
and parent_ns in existing_namespaces
and parent_ns not in listed_parents
):
try:
sub_ns = catalog_api.list_namespaces(
prefix=catalog_name, parent=UNIT_SEPARATOR.join(parts[:-1])
).namespaces
for ns in sub_ns:
existing_namespaces.add(".".join(ns))
listed_parents.add(parent_ns)
except Exception:
logger.exception(
f"Failed to list sub-namespaces for '{parent_ns}'"
)
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}
)
existing_namespaces.add(ns_name)
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("."),
location=ns_data.get("location"),
properties=ns_data.get("properties"),
)
cmd.validate()
cmd.execute(api)
logger.info(
f"Namespace '{ns_name}' created successfully in catalog '{catalog_name}'."
)
existing_namespaces.add(ns_name)
except ConflictException:
logger.info(
f"Namespace '{ns_name}' already exists in catalog '{catalog_name}'."
)
existing_namespaces.add(ns_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"),
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