diff --git a/config.yaml b/config.yaml index 41031cf..4ed53a0 100644 --- a/config.yaml +++ b/config.yaml @@ -4,7 +4,10 @@ options: ranger-admin-password: description: | - Password used for the policy manager (Web interface) + The password for Ranger Admin user. + Password can not be changed using this property after initial deployment. + It can be changed in the UI. + Password should be minimum 8 characters with min one alphabet and one numeric. default: "rangerR0cks!" type: string tls-secret-name: @@ -116,3 +119,17 @@ options: The function the charm should provide, either `admin` or `usersync`. type: string default: admin + lookup-timeout: + description: | + The default timeout for the resource auto-complete + functionality for Ranger service in ms. + type: int + default: 3000 + ranger-usersync-password: + description: | + The password for the user that synchronizes users and groups from LDAP to Ranger admin. + Password can not be changed using this property after initial deployment. + It can be changed in the UI. + Password should be minimum 8 characters with min one alphabet and one numeric. + type: string + default: rangerR0cks! diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index b331bdc..a2162aa 100644 --- a/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -331,7 +331,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 36 +LIBPATCH = 38 PYDEPS = ["ops>=2.0.0"] @@ -658,6 +658,10 @@ def set_content(self, content: Dict[str, str]) -> None: if not self.meta: return + # DPE-4182: do not create new revision if the content stay the same + if content == self.get_content(): + return + if content: self._move_to_new_label_if_needed() self.meta.set_content(content) @@ -2602,6 +2606,14 @@ def set_version(self, relation_id: int, version: str) -> None: """ self.update_relation_data(relation_id, {"version": version}) + def set_subordinated(self, relation_id: int) -> None: + """Raises the subordinated flag in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + """ + self.update_relation_data(relation_id, {"subordinated": "true"}) + class DatabaseProviderEventHandlers(EventHandlers): """Provider-side of the database relation handlers.""" @@ -2838,6 +2850,21 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the database relation has changed.""" + is_subordinate = False + remote_unit_data = None + for key in event.relation.data.keys(): + if isinstance(key, Unit) and not key.name.startswith(self.charm.app.name): + remote_unit_data = event.relation.data[key] + elif isinstance(key, Application) and key.name != self.charm.app.name: + is_subordinate = event.relation.data[key].get("subordinated") == "true" + + if is_subordinate: + if not remote_unit_data: + return + + if remote_unit_data.get("state") != "ready": + return + # Check which data has changed to emit customs events. diff = self._diff(event) diff --git a/lib/charms/grafana_k8s/v0/grafana_dashboard.py b/lib/charms/grafana_k8s/v0/grafana_dashboard.py index 1f1bc4f..dfc32dd 100644 --- a/lib/charms/grafana_k8s/v0/grafana_dashboard.py +++ b/lib/charms/grafana_k8s/v0/grafana_dashboard.py @@ -219,7 +219,7 @@ def __init__(self, *args): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 35 +LIBPATCH = 36 logger = logging.getLogger(__name__) @@ -1050,6 +1050,7 @@ def __init__( self.framework.observe(self._charm.on.leader_elected, self._update_all_dashboards_from_dir) self.framework.observe(self._charm.on.upgrade_charm, self._update_all_dashboards_from_dir) + self.framework.observe(self._charm.on.config_changed, self._update_all_dashboards_from_dir) self.framework.observe( self._charm.on[self._relation_name].relation_created, diff --git a/lib/charms/loki_k8s/v0/loki_push_api.py b/lib/charms/loki_k8s/v0/loki_push_api.py index d5217f3..16e1294 100644 --- a/lib/charms/loki_k8s/v0/loki_push_api.py +++ b/lib/charms/loki_k8s/v0/loki_push_api.py @@ -16,9 +16,10 @@ applications such as pebble, or charmed operators of workloads such as grafana-agent or promtail, that can communicate with loki directly. -- `LogProxyConsumer`: This object can be used by any Charmed Operator which needs to -send telemetry, such as logs, to Loki through a Log Proxy by implementing the consumer side of the -`loki_push_api` relation interface. +- `LogProxyConsumer`: DEPRECATED. +This object can be used by any Charmed Operator which needs to send telemetry, such as logs, to +Loki through a Log Proxy by implementing the consumer side of the `loki_push_api` relation +interface. Filtering logs in Loki is largely performed on the basis of labels. In the Juju ecosystem, Juju topology labels are used to uniquely identify the workload which generates telemetry like logs. @@ -38,13 +39,14 @@ - `charm`: A reference to the parent (Loki) charm. - `relation_name`: The name of the relation that the charm uses to interact - with its clients, which implement `LokiPushApiConsumer` or `LogProxyConsumer`. + with its clients, which implement `LokiPushApiConsumer` or `LogProxyConsumer` + (note that LogProxyConsumer is deprecated). If provided, this relation name must match a provided relation in metadata.yaml with the `loki_push_api` interface. The default relation name is "logging" for `LokiPushApiConsumer` and "log-proxy" for - `LogProxyConsumer`. + `LogProxyConsumer` (note that LogProxyConsumer is deprecated). For example, a provider's `metadata.yaml` file may look as follows: @@ -219,6 +221,9 @@ def __init__(self, *args): ## LogProxyConsumer Library Usage +> Note: This object is deprecated. Consider migrating to LogForwarder (see v1/loki_push_api) with +> the release of Juju 3.6 LTS. + Let's say that we have a workload charm that produces logs, and we need to send those logs to a workload implementing the `loki_push_api` interface, such as `Loki` or `Grafana Agent`. @@ -480,7 +485,7 @@ def _alert_rules_error(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 29 +LIBPATCH = 30 PYDEPS = ["cosl"] @@ -1539,7 +1544,8 @@ def __init__( the Loki API endpoint to push logs. It is intended for workloads that can speak loki_push_api (https://grafana.com/docs/loki/latest/api/#push-log-entries-to-loki), such as grafana-agent. - (If you only need to forward a few workload log files, then use LogProxyConsumer.) + (If you need to forward workload stdout logs, then use v1/loki_push_api.LogForwarder; if + you need to forward log files, then use LogProxyConsumer.) `LokiPushApiConsumer` can be instantiated as follows: @@ -1728,6 +1734,9 @@ class LogProxyEvents(ObjectEvents): class LogProxyConsumer(ConsumerBase): """LogProxyConsumer class. + > Note: This object is deprecated. Consider migrating to v1/loki_push_api.LogForwarder with the + > release of Juju 3.6 LTS. + The `LogProxyConsumer` object provides a method for attaching `promtail` to a workload in order to generate structured logging data from applications which traditionally log to syslog or do not have native Loki integration. diff --git a/src/charm.py b/src/charm.py index 4916517..76136be 100755 --- a/src/charm.py +++ b/src/charm.py @@ -276,6 +276,7 @@ def _configure_ranger_admin(self, container): "OPENSEARCH_ENABLED": opensearch.get("is_enabled"), "RANGER_ADMIN_PWD": self.config["ranger-admin-password"], "JAVA_OPTS": f"-Duser.timezone=UTC0 -Djavax.net.ssl.trustStorePassword={self._state.truststore_pwd}", + "RANGER_USERSYNC_PWD": self.config["ranger-usersync-password"], } logger.info(context) config = render("admin-config.jinja", context) @@ -309,6 +310,7 @@ def _configure_ranger_usersync(self, container): context.update( { "POLICY_MGR_URL": self.config["policy-mgr-url"], + "RANGER_USERSYNC_PWD": self.config["ranger-usersync-password"], } ) config = render("ranger-usersync-config.jinja", context) @@ -319,6 +321,29 @@ def _configure_ranger_usersync(self, container): ) return USERSYNC_ENTRYPOINT, context + def _validate_password(self, password, config_key, state_key): + """Validate that the admin and usersync passwords are not changed after deployment. + + Args: + password: the deployment password. + config_key: the config key for the password. + state_key: the key the password is stored in state. + + Raises: + ValueError: in case the password has been changed. + """ + if password is None: + if self.unit.is_leader(): + setattr(self._state, state_key, self.config[config_key]) + logger.info(self._state.ranger_admin_password) + elif password != self.config[config_key]: + message = ( + f"value of '{config_key}' config cannot be changed after deployment. " + f"Value should be {password}" + ) + logger.error(message) + raise ValueError(message) + def validate(self): """Validate that configuration and relations are valid and ready. @@ -334,6 +359,21 @@ def validate(self): if self.config["charm-function"].value == "usersync": self.ldap.validate() + ranger_admin_password = self._state.ranger_admin_password + ranger_usersync_password = self._state.ranger_usersync_password + + logger.info(ranger_admin_password) + self._validate_password( + ranger_admin_password, + "ranger-admin-password", + "ranger_admin_password", + ) + self._validate_password( + ranger_usersync_password, + "ranger-usersync-password", + "ranger_usersync_password", + ) + def update(self, event): """Update the Ranger server configuration and re-plan its execution. diff --git a/src/relations/provider.py b/src/relations/provider.py index 78aeacc..c71541d 100644 --- a/src/relations/provider.py +++ b/src/relations/provider.py @@ -144,6 +144,9 @@ def _create_ranger_service(self, ranger, data, event): ) service.configs = { "username": f"relation_id_{event.relation.id}", + "resource.lookup.timeout.value.in.ms": self.charm.config[ + "lookup-timeout" + ], } for key, value in data.items(): if key not in ["name", "type"]: diff --git a/src/structured_config.py b/src/structured_config.py index a482985..1e15c5a 100644 --- a/src/structured_config.py +++ b/src/structured_config.py @@ -57,9 +57,10 @@ class CharmConfig(BaseConfigModel): sync_ldap_user_group_name_attribute: Optional[str] sync_ldap_deltasync: bool sync_interval: Optional[int] - ranger_usersync_password: Optional[str] + ranger_usersync_password: str policy_mgr_url: str charm_function: FunctionType + lookup_timeout: int @validator("*", pre=True) @classmethod @@ -113,3 +114,47 @@ def sync_ldap_url_validator(cls, value: str) -> Optional[str]: if re.match(ldap_url_pattern, value) is not None: return value raise ValueError("Value incorrectly formatted.") + + @validator("lookup_timeout") + @classmethod + def lookup_timeout_validator(cls, value: str) -> Optional[int]: + """Check validity of `lookup_timeout` field. + + Args: + value: timeout value + + Returns: + int_value: integer for service configuration + + Raises: + ValueError: in the case when the value is out of range + """ + int_value = int(value) + if 1000 <= int_value <= 10000: + return int_value + raise ValueError("Value out of range.") + + @validator("ranger_admin_password", "ranger_usersync_password") + @classmethod + def password_validator(cls, value: str) -> str: + """Validate if the password meets the following requirements. + + - Minimum 8 characters in length + - Contains at least one alphabetic character + - Contains at least one numeric character + + Args: + value: The password to validate. + + Returns: + value: The validated password if it meets the requirements. + + Raises: + ValueError: If the password does not meet the requirements. + """ + pattern = re.compile( + r"^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_])[A-Za-z\d\W_]{8,}$" + ) + if pattern.match(value): + return value + raise ValueError("Password does not match requirements.") diff --git a/templates/admin-config.jinja b/templates/admin-config.jinja index 00b5a8c..494b3f5 100644 --- a/templates/admin-config.jinja +++ b/templates/admin-config.jinja @@ -72,10 +72,10 @@ db_password={{ DB_PWD }} # change password. Password for below mentioned users can be changed only once using this property. #PLEASE NOTE :: Password should be minimum 8 characters with min one alphabet and one numeric. -rangerAdmin_password={{ RANGER_ADMIN_PWD | default("rangerR0cks!") }} -rangerTagsync_password={{ RANGER_ADMIN_PWD | default("rangerR0cks!") }} -rangerUsersync_password={{ RANGER_ADMIN_PWD | default("rangerR0cks!") }} -keyadmin_password={{ RANGER_ADMIN_PWD | default("rangerR0cks!") }} +rangerAdmin_password={{ RANGER_ADMIN_PWD }} +rangerTagsync_password={{ RANGER_ADMIN_PWD }} +rangerUsersync_password={{ RANGER_USERSYNC_PWD }} +keyadmin_password={{ RANGER_ADMIN_PWD }} #Source for Audit Store. Currently solr and elasticsearch are supported. diff --git a/templates/ranger-usersync-config.jinja b/templates/ranger-usersync-config.jinja index 92de7f3..4424256 100644 --- a/templates/ranger-usersync-config.jinja +++ b/templates/ranger-usersync-config.jinja @@ -27,7 +27,7 @@ unix_group=ranger # This password should be changed from the default. # Please note that this password should be as per rangerusersync user in ranger admin. -rangerUsersync_password= rangerR0cks! +rangerUsersync_password= {{ RANGER_USERSYNC_PWD }} hadoop_conf=/etc/hadoop/conf diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 91f53b3..c626591 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -36,6 +36,7 @@ async def deploy(ops_test: OpsTest): resources=resources, application_name=APP_NAME, num_units=1, + config={"ranger-usersync-password": "P@ssw0rd1234"}, ) await ops_test.model.wait_for_idle( diff --git a/tests/integration/test_usersync.py b/tests/integration/test_usersync.py index 13e7877..147bbca 100644 --- a/tests/integration/test_usersync.py +++ b/tests/integration/test_usersync.py @@ -31,6 +31,7 @@ async def test_user_sync(self, ops_test: OpsTest): ranger_config = { "charm-function": "usersync", + "ranger-usersync-password": "P@ssw0rd1234", } charm = await ops_test.build_charm(".") diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5a728bd..06677cb 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -132,6 +132,7 @@ def test_admin_ready(self): "OPENSEARCH_PWD": None, "OPENSEARCH_PORT": None, "OPENSEARCH_USER": None, + "RANGER_USERSYNC_PWD": "rangerR0cks!", }, } }, @@ -171,6 +172,7 @@ def test_usersync_ready(self): "startup": "enabled", "environment": { "POLICY_MGR_URL": "http://ranger-k8s:6080", + "RANGER_USERSYNC_PWD": "rangerR0cks!", "SYNC_GROUP_USER_MAP_SYNC_ENABLED": True, "SYNC_GROUP_SEARCH_ENABLED": True, "SYNC_GROUP_SEARCH_BASE": "dc=canonical,dc=dev,dc=com", @@ -208,10 +210,10 @@ def test_config_changed(self): simulate_admin_lifecycle(harness) # Update the config. - self.harness.update_config({"ranger-admin-password": "secure-pass"}) + self.harness.update_config({"ranger-admin-password": "s3cure-pass"}) # The new plan reflects the change. - want_admin_password = "secure-pass" # nosec + want_admin_password = "rangerR0cks!" # nosec got_admin_password = harness.get_container_pebble_plan( "ranger" ).to_dict()["services"]["ranger"]["environment"]["RANGER_ADMIN_PWD"] @@ -221,7 +223,10 @@ def test_config_changed(self): # The Maintenance Status is set with replan message. self.assertEqual( harness.model.unit.status, - MaintenanceStatus("replanning application"), + BlockedStatus( + "value of 'ranger-admin-password' config cannot be changed after deployment. " + "Value should be rangerR0cks!" + ), ) def test_ingress(self): diff --git a/tests/unit/test_structured_config.py b/tests/unit/test_structured_config.py index 808691c..a07d58a 100644 --- a/tests/unit/test_structured_config.py +++ b/tests/unit/test_structured_config.py @@ -49,6 +49,33 @@ def test_string_values(_harness) -> None: check_valid_values(_harness, "sync-ldap-url", accepted_values) +def test_password_fields(_harness) -> None: + """Test password fields validation.""" + erroneous_passwords = [ + "onlyletters", # No numbers + "12345678", # No letters + "NoSpecialChar123", # No special characters + "Short1!", # Too short + ] + + valid_passwords = [ + "Valid1Pass!", + "AnotherValid2#Password", + "Password1$", + "P@ssw0rd1234", + ] + + check_invalid_values( + _harness, "ranger-admin-password", erroneous_passwords + ) + check_valid_values(_harness, "ranger-admin-password", valid_passwords) + + check_invalid_values( + _harness, "ranger-usersync-password", erroneous_passwords + ) + check_valid_values(_harness, "ranger-usersync-password", valid_passwords) + + def check_valid_values(_harness, field: str, accepted_values: list) -> None: """Check the correctness of the passed values for a field.