diff --git a/actions.yaml b/actions.yaml index 0ac4db047..661dc59a0 100644 --- a/actions.yaml +++ b/actions.yaml @@ -11,7 +11,7 @@ get-password: username: type: string description: The username, the default value 'operator'. - Possible values - operator, backup. + Possible values - operator, backup, monitor. set-password: description: Change the admin user's password, which is used by charm. @@ -20,7 +20,7 @@ set-password: username: type: string description: The username, the default value 'operator'. - Possible values - operator, backup. + Possible values - operator, backup, monitor. password: type: string description: The password will be auto-generated if this option is not specified. diff --git a/documentation/tutorial.md b/documentation/tutorial.md index 07851f52e..9b9223df1 100644 --- a/documentation/tutorial.md +++ b/documentation/tutorial.md @@ -131,16 +131,16 @@ unit-mongodb-0: UnitId: mongodb/0 id: "2" results: - admin-password: + password: status: completed timing: completed: 2022-12-02 11:30:01 +0000 UTC enqueued: 2022-12-02 11:29:57 +0000 UTC started: 2022-12-02 11:30:01 +0000 UTC ``` -Use the password under the result: `admin-password`: +Use the password under the result: `password`: ```shell -export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep admin-password| awk '{print $2}') +export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep password| awk '{print $2}') ``` **Retrieving the hosts:** The hosts are the units hosting the MongoDB application. The host’s IP address can be found with `juju status`: @@ -490,14 +490,14 @@ unit-mongodb-0: UnitId: mongodb/0 id: "2" results: - admin-password: + password: status: completed timing: completed: 2022-12-02 11:30:01 +0000 UTC enqueued: 2022-12-02 11:29:57 +0000 UTC started: 2022-12-02 11:30:01 +0000 UTC ``` -The admin password is under the result: `admin-password`. +The admin password is under the result: `password`. ### Rotate the admin password @@ -511,18 +511,18 @@ unit-mongodb-0: UnitId: mongodb/0 id: "4" results: - admin-password: + password: status: completed timing: completed: 2022-12-02 14:53:30 +0000 UTC enqueued: 2022-12-02 14:53:25 +0000 UTC started: 2022-12-02 14:53:28 +0000 UTC ``` -The admin password is under the result: `admin-password`. It should be different from your previous password. +The admin password is under the result: `password`. It should be different from your previous password. *Note when you change the admin password you will also need to update the admin password the in MongoDB URI; as the old password will no longer be valid.* Update the DB password used in the URI and update the URI: ```shell -export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep admin-password| awk '{print $2}') +export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep password| awk '{print $2}') export URI=mongodb://$DB_USERNAME:$DB_PASSWORD@$HOST_IP/$DB_NAME?replicaSet=$REPL_SET_NAME ``` @@ -537,18 +537,18 @@ unit-mongodb-0: UnitId: mongodb/0 id: "4" results: - admin-password: + password: status: completed timing: completed: 2022-12-02 14:53:30 +0000 UTC enqueued: 2022-12-02 14:53:25 +0000 UTC started: 2022-12-02 14:53:28 +0000 UTC ``` -The admin password under the result: `admin-password` should match whatever you passed in when you entered the command. +The admin password under the result: `password` should match whatever you passed in when you entered the command. *Note that when you change the admin password you will also need to update the admin password in the MongoDB URI, as the old password will no longer be valid.* To update the DB password used in the URI: ```shell -export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep admin-password| awk '{print $2}') +export DB_PASSWORD=$(juju run-action mongodb/leader get-password --wait | grep password| awk '{print $2}') export URI=mongodb://$DB_USERNAME:$DB_PASSWORD@$HOST_IP/$DB_NAME?replicaSet=$REPL_SET_NAME ``` diff --git a/lib/charms/mongodb/v0/mongodb.py b/lib/charms/mongodb/v0/mongodb.py index 48160e55f..3561572bf 100644 --- a/lib/charms/mongodb/v0/mongodb.py +++ b/lib/charms/mongodb/v0/mongodb.py @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) # List of system usernames needed for correct work on the charm. -CHARM_USERS = ["operator", "backup"] +CHARM_USERS = ["operator", "backup", "monitor"] @dataclass @@ -331,6 +331,11 @@ def _get_roles(config: MongoDBConfiguration) -> List[dict]: {"role": "readWriteAnyDatabase", "db": "admin"}, {"role": "userAdmin", "db": "admin"}, ], + "monitor": [ + {"role": "explainRole", "db": "admin"}, + {"role": "clusterMonitor", "db": "admin"}, + {"role": "read", "db": "local"}, + ], "backup": [ {"db": "admin", "role": "readWrite", "collection": ""}, {"db": "admin", "role": "backup"}, diff --git a/lib/charms/mongodb/v0/mongodb_backups.py b/lib/charms/mongodb/v0/mongodb_backups.py index 86509aa18..6b9044f49 100644 --- a/lib/charms/mongodb/v0/mongodb_backups.py +++ b/lib/charms/mongodb/v0/mongodb_backups.py @@ -429,14 +429,14 @@ def _format_backup_list(self, backup_list) -> str: @property def _backup_config(self) -> MongoDBConfiguration: """Construct the config object for backup user and creates user if necessary.""" - if not self.charm.get_secret("app", "backup_password"): - self.charm.set_secret("app", "backup_password", generate_password()) + if not self.charm.get_secret("app", "backup-password"): + self.charm.set_secret("app", "backup-password", generate_password()) return MongoDBConfiguration( replset=self.charm.app.name, database="", username="backup", - password=self.charm.get_secret("app", "backup_password"), + password=self.charm.get_secret("app", "backup-password"), hosts=[ self.charm._unit_ip(self.charm.unit) ], # pbm cannot make a direct connection if multiple hosts are used diff --git a/src/charm.py b/src/charm.py index f113ddce3..20b555c7f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -67,6 +67,10 @@ MONGO_USER = "mongodb" MONGO_DATA_DIR = "/data/db" PBM_PRIVILEGES = {"resource": {"anyResource": True}, "actions": ["anyAction"]} +MONITOR_PRIVILEGES = { + "resource": {"db": "", "collection": ""}, + "actions": ["listIndexes", "listCollections", "dbStats", "dbHash", "collStats", "find"], +} # We expect the MongoDB container to use the default ports MONGODB_PORT = 27017 @@ -441,7 +445,7 @@ def _on_get_password(self, event: ops.charm.ActionEvent) -> None: ) return - event.set_results({f"{username}-password": self.get_secret("app", f"{username}-password")}) + event.set_results({"password": self.get_secret("app", f"{username}-password")}) def _on_set_password(self, event: ops.charm.ActionEvent) -> None: """Set the password for the admin user.""" @@ -475,7 +479,7 @@ def _on_set_password(self, event: ops.charm.ActionEvent) -> None: return self.set_secret("app", f"{username}-password", new_password) - event.set_results({f"{username}-password": new_password}) + event.set_results({"password": new_password}) def _open_port_tcp(self, port: int) -> None: """Open the given port. @@ -624,6 +628,7 @@ def _initialise_replica_set(self, event: ops.charm.StartEvent) -> None: logger.info("User initialization") self._init_admin_user() self._init_backup_user() + self._init_monitor_user() logger.info("Manage relations") self.client_relations.oversee_users(None, None) except subprocess.CalledProcessError as e: @@ -746,6 +751,23 @@ def mongodb_config(self) -> MongoDBConfiguration: tls_internal=internal_ca is not None, ) + @property + def monitor_config(self) -> MongoDBConfiguration: + """Generates a MongoDBConfiguration object for this deployment of MongoDB.""" + if not self.get_secret("app", "monitor-password"): + self.set_secret("app", "monitor-password", generate_password()) + + return MongoDBConfiguration( + replset=self.app.name, + database="", + username="monitor", + password=self.get_secret("app", "monitor-password"), + hosts=set(self._unit_ips), + roles={"monitor"}, + tls_external=self.tls.get_tls_files("unit") is not None, + tls_internal=self.tls.get_tls_files("app") is not None, + ) + @property def unit_peer_data(self) -> Dict: """Peer relation data object.""" @@ -803,6 +825,24 @@ def _init_admin_user(self) -> None: logger.debug("User created") self.app_peer_data["user_created"] = "True" + @retry( + stop=stop_after_attempt(3), + wait=wait_fixed(5), + reraise=True, + before=before_log(logger, logging.DEBUG), + ) + def _init_monitor_user(self): + """Creates the monitor user on the MongoDB database.""" + if "monitor_user_created" in self.app_peer_data: + return + + with MongoDBConnection(self.mongodb_config) as mongo: + logger.debug("creating the monitor user roles...") + mongo.create_role(role_name="explainRole", privileges=MONITOR_PRIVILEGES) + logger.debug("creating the monitor user...") + mongo.create_user(self.monitor_config) + self.app_peer_data["monitor_user_created"] = "True" + @retry( stop=stop_after_attempt(3), wait=wait_fixed(5), diff --git a/tests/integration/ha_tests/helpers.py b/tests/integration/ha_tests/helpers.py index 6fff9c43e..142a2ba89 100644 --- a/tests/integration/ha_tests/helpers.py +++ b/tests/integration/ha_tests/helpers.py @@ -108,7 +108,7 @@ async def get_password(ops_test: OpsTest, app, down_unit=None) -> str: action = await ops_test.model.units.get(f"{app}/{unit_id}").run_action("get-password") action = await action.wait() - return action.results["operator-password"] + return action.results["password"] async def fetch_primary( diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 67b094da0..cd166120b 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -26,7 +26,7 @@ def unit_uri(ip_address: str, password, app=APP_NAME) -> str: return f"mongodb://operator:" f"{password}@" f"{ip_address}:{PORT}/admin?replicaSet={app}" -async def get_password(ops_test: OpsTest, app=APP_NAME) -> str: +async def get_password(ops_test: OpsTest, app=APP_NAME, username="operator") -> str: """Use the charm action to retrieve the password from provided unit. Returns: @@ -36,9 +36,11 @@ async def get_password(ops_test: OpsTest, app=APP_NAME) -> str: unit_name = ops_test.model.applications[app].units[0].name unit_id = unit_name.split("/")[1] - action = await ops_test.model.units.get(f"{app}/{unit_id}").run_action("get-password") + action = await ops_test.model.units.get(f"{app}/{unit_id}").run_action( + "get-password", **{"username": username} + ) action = await action.wait() - return action.results["operator-password"] + return action.results["password"] @retry( diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 4c3fc8e18..ca5eabcd4 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -134,7 +134,7 @@ async def test_set_password_action(ops_test: OpsTest) -> None: unit = await find_unit(ops_test, leader=True) action = await unit.run_action("set-password") action = await action.wait() - new_password = action.results["operator-password"] + new_password = action.results["password"] assert new_password != old_password new_password_reported = await get_password(ops_test) assert new_password == new_password_reported @@ -152,7 +152,7 @@ async def test_set_password_action(ops_test: OpsTest) -> None: old_password = await get_password(ops_test) action = await unit.run_action("set-password", **{"password": "safe_pass"}) action = await action.wait() - new_password = action.results["operator-password"] + new_password = action.results["password"] assert new_password != old_password new_password_reported = await get_password(ops_test) assert "safe_pass" == new_password_reported @@ -167,6 +167,22 @@ async def test_set_password_action(ops_test: OpsTest) -> None: client.close() +async def test_monitor_user(ops_test: OpsTest) -> None: + """Test verifies that the monitor user can perform operations such as 'rs.conf()'.""" + unit = ops_test.model.applications[APP_NAME].units[0] + password = await get_password(ops_test, "mongodb", "monitor") + replica_set_hosts = [ + unit.public_address for unit in ops_test.model.applications["mongodb"].units + ] + hosts = ",".join(replica_set_hosts) + replica_set_uri = f"mongodb://monitor:{password}@{hosts}/admin?replicaSet=mongodb" + + admin_mongod_cmd = f"mongo '{replica_set_uri}' --eval 'rs.conf()'" + check_monitor_cmd = f"run --unit {unit.name} -- {admin_mongod_cmd}" + return_code, _, _ = await ops_test.juju(*check_monitor_cmd.split()) + assert return_code == 0, "command rs.conf() on monitor user does not work" + + async def test_exactly_one_primary_reported_by_juju(ops_test: OpsTest) -> None: """Tests that there is exactly one replica set primary unit reported by juju.""" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index c295b9774..591555d67 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -756,7 +756,7 @@ def test_set_password(self, connection): # verify app data is updated and results are reported to user self.assertNotEqual(original_password, new_password) - action_event.set_results.assert_called_with({"operator-password": new_password}) + action_event.set_results.assert_called_with({"password": new_password}) @patch_network_get(private_address="1.1.1.1") @patch("charm.MongoDBConnection") @@ -770,7 +770,7 @@ def test_set_password_provided(self, connection): # verify app data is updated and results are reported to user self.assertEqual("canonical123", new_password) - action_event.set_results.assert_called_with({"operator-password": "canonical123"}) + action_event.set_results.assert_called_with({"password": "canonical123"}) @patch_network_get(private_address="1.1.1.1") @patch("charm.MongoDBConnection")