diff --git a/src/literals.py b/src/literals.py index 495c10e..ab4dce2 100644 --- a/src/literals.py +++ b/src/literals.py @@ -36,6 +36,18 @@ JAVA_ENV = {"JAVA_HOME": "/opt/java/openjdk"} RANGER_POLICY_PATH = "/etc/ranger" +RANGER_ACCESS_CONTROL = """\ +access-control.name=ranger +ranger.use_ugi=true +""" +RANGER_ACCESS_CONTROL_PATH = "/etc/trino/access-control.properties" + +# UNIX literals +UNIX_TYPE_MAPPING = { + "user": "passwd", + "group": "group", + "membership": "group", +} # Connector literal CONNECTOR_FIELDS = { diff --git a/src/relations/policy.py b/src/relations/policy.py index b1545b2..a0d607d 100644 --- a/src/relations/policy.py +++ b/src/relations/policy.py @@ -5,19 +5,23 @@ import logging +import yaml from ops import framework from ops.model import BlockedStatus from ops.pebble import ExecError from literals import ( JAVA_ENV, + RANGER_ACCESS_CONTROL, + RANGER_ACCESS_CONTROL_PATH, RANGER_PLUGIN_FILE, RANGER_PLUGIN_VERSION, RANGER_POLICY_PATH, TRINO_PORTS, + UNIX_TYPE_MAPPING, ) from log import log_event_handler -from utils import render +from utils import handle_exec_error, render logger = logging.getLogger(__name__) @@ -26,8 +30,8 @@ class PolicyRelationHandler(framework.Object): """Client for trino policy relations. Attributes: - plugin_version: the version of the Ranger plugin - ranger_plugin_path: the path of the unpacked ranger plugin + plugin_version: The version of the Ranger plugin + ranger_plugin_path: The path of the unpacked ranger plugin """ plugin_version = RANGER_PLUGIN_VERSION["path"] @@ -38,7 +42,7 @@ def __init__(self, charm, relation_name="policy"): Args: charm: The charm to attach the hooks to. - relation_name: the name of the relation defaults to policy. + relation_name: The name of the relation defaults to policy. """ super().__init__(charm, "policy") self.charm = charm @@ -63,7 +67,7 @@ def _on_relation_created(self, event): """Handle policy relation created. Args: - event: relation created event. + event: The relation created event. """ if not self.charm.unit.is_leader(): return @@ -78,7 +82,10 @@ def _on_relation_changed(self, event): """Handle policy relation changed. Args: - event: relation changed event. + event: Relation changed event. + + Raises: + ExecError: When failure to enable Ranger plugin. """ if not self.charm.unit.is_leader(): return @@ -101,22 +108,39 @@ def _on_relation_changed(self, event): container, policy_manager_url, policy_relation ) self._enable_plugin(container) + logger.info("Ranger plugin is enabled.") except ExecError as err: - logger.error(err) self.charm.unit.status = BlockedStatus( "Failed to enable Ranger plugin." ) - return + raise ExecError(f"Unable to enable Ranger plugin: {err}") from err + + users_and_groups = event.relation.data[event.app].get( + "user-group-configuration", None + ) + if users_and_groups: + try: + self._synchronize(users_and_groups, container) + except ExecError: + logger.exception("Failed to synchronize groups:") + event.defer() + except Exception: + logger.exception( + "An exception occurred while sychronizing Ranger groups:" + ) + self.charm.unit.status = BlockedStatus( + "Failed to synchronize Ranger groups." + ) self.charm._restart_trino(container) def _prepare_service(self, event): """Prepare service to be created in Ranger. Args: - event: relation created event + event: Relation created event Returns: - service: service values to be set in relation databag + service: Service values to be set in relation databag """ host = self.charm.config["application-name"] port = TRINO_PORTS["HTTP"] @@ -139,7 +163,10 @@ def _on_relation_broken(self, event): """Handle policy relation broken. Args: - event: relation broken event. + event: Relation broken event. + + Raises: + ExecError: When failure to disable Ranger plugin. """ if not self.charm.unit.is_leader(): return @@ -151,7 +178,11 @@ def _on_relation_broken(self, event): if not container.exists(self.ranger_plugin_path): return - self._disable_ranger_plugin(container) + try: + self._disable_ranger_plugin(container) + logger.info("Ranger plugin disabled successfully") + except ExecError as err: + raise ExecError(f"Unable to disable Ranger plugin: {err}") from err if container.exists(RANGER_POLICY_PATH): container.remove_path(RANGER_POLICY_PATH, recursive=True) @@ -161,38 +192,29 @@ def _on_relation_broken(self, event): self.charm._restart_trino(container) + @handle_exec_error def _enable_plugin(self, container): """Enable ranger plugin. Args: - container: application container - - Raises: - ExecError: in case unable to enable trino plugin + container: The application container """ command = [ "bash", "enable-trino-plugin.sh", ] - try: - container.exec( - command, - working_dir=self.ranger_plugin_path, - environment=JAVA_ENV, - ).wait_output() - logger.info("Ranger plugin enabled successfully") - except ExecError as err: - logger.error(err.stdout) - raise + container.exec( + command, + working_dir=self.ranger_plugin_path, + environment=JAVA_ENV, + ).wait_output() + @handle_exec_error def _unpack_plugin(self, container): """Unpack ranger plugin tar. Args: container: application container - - Raises: - ExecError: in case unable to enable trino plugin """ if container.exists(self.ranger_plugin_path): return @@ -204,12 +226,7 @@ def _unpack_plugin(self, container): "xf", f"ranger-{tar_version}-trino-plugin.tar.gz", ] - try: - container.exec(command, working_dir="/root").wait_output() - logger.info("Ranger plugin unpacked successfully") - except ExecError as err: - logger.error(err.stdout) - raise + container.exec(command, working_dir="/root").wait_output() def _configure_plugin_properties( self, container, policy_manager_url, policy_relation @@ -234,27 +251,177 @@ def _configure_plugin_properties( make_dirs=True, permissions=0o744, ) + container.push( + RANGER_ACCESS_CONTROL_PATH, + RANGER_ACCESS_CONTROL, + make_dirs=True, + ) + + @handle_exec_error + def _synchronize(self, config, container): + """Handle synchronization of Ranger users, groups and group membership. + + Args: + config: String of user and group configuration from Ranger relation. + container: Trino application container. + """ + data = yaml.safe_load(config) + self._sync(container, data["users"], "user") + self._sync(container, data["groups"], "group") + self._sync(container, data["memberships"], "membership") + logger.info("User synchronization successful!") + + @handle_exec_error + def _sync(self, container, apply_objects, member_type): + """Synchronize Unix users and groups. + + Args: + container: The container to run the command in. + apply_objects: The users and group mappings to be applied to Trino. + member_type: The type of Unix member, "user", "group" or "membership". + """ + # get existing values + existing = self._get_unix(container, member_type) + + # get values to apply + apply = self._transform_apply_values(apply_objects, member_type) + + # create members + to_create = [item for item in apply if item not in existing] + self._create_members(container, member_type, to_create) + + # delete memberships + if member_type == "membership": + to_delete = [item for item in existing if item not in apply] + self._delete_memberships(container, to_delete) + + @handle_exec_error + def _get_unix(self, container, member_type): + """Get a list of Unix users or groups from the specified container. + Args: + container: The container to run the command in. + member_type: The type of Unix member, "user", "group" or "membership". + + Returns: + values: Either a list of usernames/groups or a list of (group, user) tuples. + """ + member_type_mapping = UNIX_TYPE_MAPPING + command = ["getent", member_type_mapping[member_type]] + + out = container.exec(command).wait_output() + + # Split the output to rows. + rows = out[0].strip().split("\n") + if member_type == "membership": + # Create a list of (group, user) tuples. + members = [(row.split(":")[0], row.split(":")[3]) for row in rows] + values = [] + for group, users in members: + values += [ + (group, user.strip()) + for user in users.split(",") + if user.strip() + ] + else: + # Split the output to rows and create a list of user or group values. + values = [row.split(":")[0] for row in rows] + return values + + def _transform_apply_values(self, data, member_type): + """Get list of users, groups or memberships to apply from configuration file. + + Args: + data: User, group or membership data. + member_type: The type of Unix member, "user", "group" or "membership". + + Returns: + List of users, groups or memberships to apply. + """ + if member_type in ["user", "group"]: + return [member["name"] for member in data] + + membership_tuples = [ + (membership["groupname"], user) + for membership in data + for user in membership["users"] + ] + return membership_tuples + + @handle_exec_error + def _create_members(self, container, member_type, to_create): + """Create Unix users, groups or memberships. + + Args: + container: The container to run the command in. + member_type: The type of Unix member, "user", "group" or "membership". + to_create: List of users, groups or memberships to create. + """ + for member in to_create: + logger.debug(f"Attempting to create {member_type}: {member}") + + if member_type == "group": + command = [f"{member_type}add", member] + elif member_type == "user": + command = [f"{member_type}add", "-c", "ranger", member] + elif member_type == "membership": + command = ["usermod", "-aG", member[0], member[1]] + + container.exec(command).wait_output() + + @handle_exec_error + def _delete_memberships(self, container, to_delete): + """Delete Unix group memberships. + + Args: + container: The container to run the command in. + to_delete: List of memberships to delete. + """ + ranger_users = self._get_ranger_users(container) + for membership in to_delete: + if membership[1] in ranger_users: + logger.debug(f"Attempting to delete membership {membership}") + container.exec( + ["deluser", membership[1], membership[0]] + ).wait_output() + + @handle_exec_error + def _get_ranger_users(self, container): + """Get users for which the Gecos information contains `ranger`. + + Args: + container: The container to run the command in. + + Returns: + ranger_users: The users created by the Ranger relation. + """ + out = container.exec(["getent", "passwd"]).wait_output() + rows = out[0].strip().split("\n") + ranger_users = [] + + for row in rows: + user = row.strip().split(":") + if "ranger" in user[4]: + ranger_users.append(user[0]) + + return ranger_users + + @handle_exec_error def _disable_ranger_plugin(self, container): """Disable ranger plugin. Args: container: application container - - Raises: - ExecError: in case unable to enable trino plugin """ command = [ "bash", "disable-trino-plugin.sh", ] - try: - container.exec( - command, - working_dir=self.ranger_plugin_path, - environment=JAVA_ENV, - ).wait_output() - logger.info("Ranger plugin disabled successfully") - except ExecError as err: - logger.error(err.stdout) - raise + container.exec( + command, + working_dir=self.ranger_plugin_path, + environment=JAVA_ENV, + ).wait_output() + + if container.exists(RANGER_ACCESS_CONTROL_PATH): + container.remove_path(RANGER_ACCESS_CONTROL_PATH) diff --git a/src/utils.py b/src/utils.py index e67634b..14a6224 100644 --- a/src/utils.py +++ b/src/utils.py @@ -13,6 +13,7 @@ import bcrypt from jinja2 import Environment, FileSystemLoader from ops.model import Container +from ops.pebble import ExecError logger = logging.getLogger(__name__) @@ -168,3 +169,36 @@ def push_files(container, file_path, destination, permissions): container.push( destination, file_content, make_dirs=True, permissions=permissions ) + + +def handle_exec_error(func): + """Handle ExecError while executing command on application container. + + Args: + func: The function to decorate. + + Returns: + wrapper: A decorated function that raises an error on failure. + """ + + def wrapper(*args, **kwargs): + """Execute wrapper for the decorated function and handle errors. + + Args: + args: Positional arguments passed to the decorated function. + kwargs: Keyword arguments passed to the decorated function. + + Returns: + result: The result of the decorated function if successful. + + Raises: + ExecError: In case the command fails to execute successfully. + """ + try: + result = func(*args, **kwargs) + return result + except ExecError: + logger.exception(f"Failed to execute {func.__name__}:") + raise + + return wrapper diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index ad1d191..6722b6b 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -10,8 +10,9 @@ import json import logging -from unittest import TestCase, mock +from unittest import TestCase +from ops import testing from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness @@ -28,6 +29,20 @@ RANGER_PROPERTIES_PATH = ( "/root/ranger-3.0.0-SNAPSHOT-trino-plugin/install.properties" ) +POLICY_MGR_URL = "http://ranger-k8s:6080" +GROUP_MANAGEMENT = """\ + users: + - name: user1 + firstname: One + lastname: User + email: user1@canonical.com + memberships: + - groupname: commercial-systems + users: [user1] + groups: + - name: commercial-systems + description: commercial systems team +""" logger = logging.getLogger(__name__) @@ -253,20 +268,41 @@ def test_policy_relation_created(self): "jdbc.url": "jdbc:trino://trino-k8s:8080", } - @mock.patch("charm.PolicyRelationHandler._unpack_plugin") - @mock.patch("charm.PolicyRelationHandler._enable_plugin") - def test_policy_relation_changed(self, _unpack_plugin, _enable_plugin): + def test_policy_relation_changed(self): """Add policy_manager_url to the relation databag.""" harness = self.harness simulate_lifecycle(harness) container = harness.model.unit.get_container("trino") + # Create the relation rel_id = harness.add_relation("policy", "trino-k8s") harness.add_relation_unit(rel_id, "trino-k8s/0") - data = {"ranger-k8s": {"policy_manager_url": "http://ranger-k8s:6080"}} + # Create handlers for Container.exec() commands + for command in [ + "bash", + "tar", + "useradd", + "groupadd", + "usermod", + "deluser", + ]: + harness.handle_exec("trino", [command], result=0) + harness.handle_exec("trino", ["getent"], handler=group_handler) + + # Create and emit the policy `_on_relation_changed` event. + data = { + "ranger-k8s": { + "policy_manager_url": POLICY_MGR_URL, + "user-group-configuration": GROUP_MANAGEMENT, + }, + } event = make_policy_relation_event(rel_id, data) harness.charm.policy._on_relation_changed(event) + + self.assertTrue( + event.relation.data["ranger-k8s"]["user-group-configuration"] + ) self.assertTrue(container.exists(RANGER_PROPERTIES_PATH)) def test_policy_relation_broken(self): @@ -281,6 +317,10 @@ def test_policy_relation_broken(self): data = {"ranger-k8s": {}} event = make_policy_relation_event(rel_id, data) harness.charm.policy._on_relation_broken(event) + + self.assertFalse( + event.relation.data["ranger-k8s"].get("user-group-configuration") + ) self.assertFalse(container.exists(RANGER_PROPERTIES_PATH)) @@ -327,6 +367,22 @@ def make_policy_relation_event(rel_id, data): ) +def group_handler(args): + """Execution handler for getent command. + + Args: + args: execution arguments. + + Returns: + The execution result. + """ + if args.command == ["getent", "passwd"]: + out = "user2:x:1002:1002:ranger:/home/user2:/bin/sh" + elif args.command == ["getent", "group"]: + out = "marketing:x:1004:user2" + return testing.ExecResult(stdout=out) + + class MockEvent: """Mock event action class."""