Skip to content
20 changes: 20 additions & 0 deletions client/python/cli/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def options_get(key, f=lambda x: x):
set_properties = Parser.parse_properties(options_get(Arguments.SET_PROPERTY))
remove_properties = options_get(Arguments.REMOVE_PROPERTY)
catalog_client_scopes = options_get(Arguments.CATALOG_CLIENT_SCOPE)
parameters = Parser.parse_properties(options_get(Arguments.PARAMETERS)),

command = None
if options.command == Commands.CATALOGS:
Expand Down Expand Up @@ -169,6 +170,25 @@ def options_get(key, f=lambda x: x):
command = ProfilesCommand(
subcommand, profile_name=options_get(Arguments.PROFILE)
)
elif options.command == Commands.POLICIES:
from cli.command.policies import PoliciesCommand

subcommand = options_get(f"{Commands.POLICIES}_subcommand")
command = PoliciesCommand(
subcommand,
catalog_name=options_get(Arguments.CATALOG),
namespace=options_get(Arguments.NAMESPACE),
policy_name=options_get(Arguments.POLICY),
policy_file=options_get(Arguments.POLICY_FILE),
policy_type=options_get(Arguments.POLICY_TYPE),
policy_description=options_get(Arguments.POLICY_DESCRIPTION),
target_name=options_get(Arguments.TARGET_NAME),
parameters={} if parameters is None else parameters,
detach_all=options_get(Arguments.DETACH_ALL),
applicable=options_get(Arguments.APPLICABLE),
attachment_type=options_get(Arguments.ATTACHMENT_TYPE),
attachment_path=options_get(Arguments.ATTACHMENT_PATH),
)

if command is not None:
command.validate()
Expand Down
22 changes: 4 additions & 18 deletions client/python/cli/command/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
# under the License.
#
import json
import re
from dataclasses import dataclass
from typing import Dict, Optional, List

from pydantic import StrictStr

from cli.command import Command
from cli.command.utils import get_catalog_api_client
from cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
from cli.options.option_tree import Argument
from polaris.catalog import IcebergCatalogAPI, CreateNamespaceRequest, ApiClient, Configuration
from polaris.catalog import IcebergCatalogAPI, CreateNamespaceRequest
from polaris.management import PolarisDefaultApi


Expand Down Expand Up @@ -55,23 +55,9 @@ def validate(self):
f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
)

def _get_catalog_api(self, api: PolarisDefaultApi):
"""
Convert a management API to a catalog API
"""
catalog_host = re.match(
r"(https?://.+)/api/management", api.api_client.configuration.host
).group(1)
configuration = Configuration(
host=f"{catalog_host}/api/catalog",
username=api.api_client.configuration.username,
password=api.api_client.configuration.password,
access_token=api.api_client.configuration.access_token,
)
return IcebergCatalogAPI(ApiClient(configuration))

def execute(self, api: PolarisDefaultApi) -> None:
catalog_api = self._get_catalog_api(api)
catalog_api_client = get_catalog_api_client(api)
catalog_api = IcebergCatalogAPI(catalog_api_client)
if self.namespaces_subcommand == Subcommands.CREATE:
req_properties = self.properties or {}
if self.location:
Expand Down
202 changes: 202 additions & 0 deletions client/python/cli/command/policies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#
# 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 json
from dataclasses import dataclass
from typing import Optional, Dict
from cli.command import Command
from cli.command.utils import get_catalog_api_client
from cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
from cli.options.option_tree import Argument
from polaris.management import PolarisDefaultApi
from polaris.catalog.api.policy_api import PolicyAPI
from polaris.catalog.models.create_policy_request import CreatePolicyRequest
from polaris.catalog.models.update_policy_request import UpdatePolicyRequest
from polaris.catalog.models.policy_attachment_target import PolicyAttachmentTarget
from polaris.catalog.models.attach_policy_request import AttachPolicyRequest
from polaris.catalog.models.detach_policy_request import DetachPolicyRequest



@dataclass
class PoliciesCommand(Command):
"""
A Command implementation to represent `polaris policies`.
"""

policies_subcommand: str
catalog_name: str
namespace: str
policy_name: str
policy_file: str
policy_type: Optional[str]
policy_description: Optional[str]
target_name: Optional[str]
parameters: Optional[Dict[str, str]]
detach_all: Optional[bool]
applicable: Optional[bool]
attachment_type: Optional[str]
attachment_path: Optional[str]

def validate(self):
if not self.catalog_name:
raise Exception(f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}")
if self.policies_subcommand in [Subcommands.CREATE, Subcommands.UPDATE]:
if not self.policy_file:
raise Exception(f"Missing required argument: {Argument.to_flag_name(Arguments.POLICY_FILE)}")
if self.policies_subcommand in [Subcommands.ATTACH, Subcommands.DETACH]:
if not self.attachment_type:
raise Exception(f"Missing required argument: {Argument.to_flag_name(Arguments.ATTACHMENT_TYPE)}")
if self.attachment_type != 'catalog' and not self.attachment_path:
raise Exception(f"'{Argument.to_flag_name(Arguments.ATTACHMENT_PATH)}' is required when attachment type is not 'catalog'")
if self.policies_subcommand == Subcommands.LIST and self.applicable and self.target_name:
if not self.namespace:
raise Exception(
f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
f" when {Argument.to_flag_name(Arguments.TARGET_NAME)} is set."
)
if self.policies_subcommand == Subcommands.LIST and not self.applicable:
if not self.namespace:
raise Exception(
f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
f" when listing policies without {Argument.to_flag_name(Arguments.APPLICABLE)} flag."
)

def execute(self, api: PolarisDefaultApi) -> None:
catalog_api_client = get_catalog_api_client(api)
policy_api = PolicyAPI(catalog_api_client)

namespace_str = self.namespace.replace('.', UNIT_SEPARATOR) if self.namespace else ""
if self.policies_subcommand == Subcommands.CREATE:
with open(self.policy_file, "r") as f:
policy = json.load(f)
policy_api.create_policy(
prefix=self.catalog_name,
namespace=namespace_str,
create_policy_request=CreatePolicyRequest(
name=self.policy_name,
type=self.policy_type,
description=self.policy_description,
content=json.dumps(policy)
)
)
elif self.policies_subcommand == Subcommands.DELETE:
policy_api.drop_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name,
detach_all=self.detach_all
)
elif self.policies_subcommand == Subcommands.GET:
print(policy_api.load_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name
).to_json())
elif self.policies_subcommand == Subcommands.LIST:
if self.applicable:
applicable_policies_list = []

if self.target_name:
# Table-like level policies
applicable_policies_list = policy_api.get_applicable_policies(
prefix=self.catalog_name,
namespace=namespace_str,
target_name=self.target_name,
policy_type=self.policy_type
).applicable_policies
elif self.namespace:
# Namespace level policies
applicable_policies_list = policy_api.get_applicable_policies(
prefix=self.catalog_name,
namespace=namespace_str,
policy_type=self.policy_type
).applicable_policies
else:
# Catalog level policies
applicable_policies_list = policy_api.get_applicable_policies(
prefix=self.catalog_name,
policy_type=self.policy_type
).applicable_policies
for policy in applicable_policies_list:
print(policy.to_json())
else:
# List all policy identifiers in the namespace
policies_response = policy_api.list_policies(
prefix=self.catalog_name,
namespace=namespace_str,
policy_type=self.policy_type
).to_json()
print(policies_response)
elif self.policies_subcommand == Subcommands.UPDATE:
with open(self.policy_file, "r") as f:
policy_document = json.load(f)
# Fetch the current policy to get its version
loaded_policy_response = policy_api.load_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name
)
if loaded_policy_response and loaded_policy_response.policy:
current_policy_version = loaded_policy_response.policy.version
else:
raise Exception(f"Could not retrieve current policy version for {self.policy_name}")

policy_api.update_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name,
update_policy_request=UpdatePolicyRequest(
description=self.policy_description,
content=json.dumps(policy_document),
current_policy_version=current_policy_version
)
)
elif self.policies_subcommand == Subcommands.ATTACH:
attachment_path_list = [] if self.attachment_type == "catalog" else self.attachment_path.split('.')

policy_api.attach_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name,
attach_policy_request=AttachPolicyRequest(
target=PolicyAttachmentTarget(
type=self.attachment_type,
path=attachment_path_list
),
parameters=self.parameters if isinstance(self.parameters, dict) else None
)
)
elif self.policies_subcommand == Subcommands.DETACH:
attachment_path_list = [] if self.attachment_type == "catalog" else self.attachment_path.split('.')

policy_api.detach_policy(
prefix=self.catalog_name,
namespace=namespace_str,
policy_name=self.policy_name,
detach_policy_request=DetachPolicyRequest(
target=PolicyAttachmentTarget(
type=self.attachment_type,
path=attachment_path_list
),
parameters=self.parameters if isinstance(self.parameters, dict) else None
)
)
else:
raise Exception(f"{self.policies_subcommand} is not supported in the CLI")
44 changes: 44 additions & 0 deletions client/python/cli/command/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#
# 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 re

from polaris.catalog.api_client import ApiClient
from polaris.catalog.configuration import Configuration
from polaris.management import PolarisDefaultApi


def get_catalog_api_client(api: PolarisDefaultApi) -> ApiClient:
"""
Convert a management API to a catalog API client
"""
mgmt_config = api.api_client.configuration
catalog_host = re.sub(r"/api/management(?:/v1)?", "/api/catalog", mgmt_config.host)
configuration = Configuration(
host=catalog_host,
username=mgmt_config.username,
password=mgmt_config.password,
access_token=mgmt_config.access_token,
)

if hasattr(mgmt_config, "proxy"):
configuration.proxy = mgmt_config.proxy
if hasattr(mgmt_config, "proxy_headers"):
configuration.proxy_headers = mgmt_config.proxy_headers

return ApiClient(configuration)
25 changes: 25 additions & 0 deletions client/python/cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class Commands:
PRIVILEGES = "privileges"
NAMESPACES = "namespaces"
PROFILES = "profiles"
POLICIES = "policies"


class Subcommands:
Expand All @@ -110,6 +111,8 @@ class Subcommands:
REVOKE = "revoke"
ACCESS = "access"
RESET = "reset"
ATTACH = "attach"
DETACH = "detach"


class Actions:
Expand Down Expand Up @@ -189,6 +192,16 @@ class Arguments:
CATALOG_EXTERNAL_ID = "catalog_external_id"
CATALOG_SIGNING_REGION = "catalog_signing_region"
CATALOG_SIGNING_NAME = "catalog_signing_name"
POLICY = "policy"
POLICY_FILE = "policy_file"
POLICY_TYPE = "policy_type"
POLICY_DESCRIPTION = "policy_description"
TARGET_NAME = "target_name"
PARAMETERS = "parameters"
DETACH_ALL = "detach_all"
APPLICABLE = "applicable"
ATTACHMENT_TYPE = "attachment_type"
ATTACHMENT_PATH = "attachment_path"
REALM = "realm"
HEADER = "header"

Expand Down Expand Up @@ -373,6 +386,18 @@ class Namespaces:
LOCATION = "If specified, the location at which to store the namespace and entities inside it"
PARENT = "If specified, list namespaces inside this parent namespace"

class Policies:
POLICY = "The name of a policy"
POLICY_FILE = "The path to a JSON file containing the policy definition"
POLICY_TYPE = "The type of the policy, e.g., 'system.data-compaction'"
POLICY_DESCRIPTION = "An optional description for the policy."
TARGET_NAME = "The name of the target entity (e.g., table name, namespace name)."
PARAMETERS = "Optional key-value pairs for the attachment/detachment, e.g., key=value. Can be specified multiple times."
DETACH_ALL = "When set to true, the policy will be deleted along with all its attached mappings."
APPLICABLE = "When set, lists policies applicable to the target entity (considering inheritance) instead of policies defined directly in the target."
ATTACHMENT_TYPE = "The type of entity to attach the policy to, e.g., 'catalog', 'namespace', or table-like."
ATTACHMENT_PATH = "The path of the entity to attach the policy to, e.g., 'ns1.tb1'. Not required for catalog-level attachment."


UNIT_SEPARATOR = chr(0x1F)
CLIENT_ID_ENV = "CLIENT_ID"
Expand Down
Loading