Skip to content

Commit

Permalink
Add monitor user (#112)
Browse files Browse the repository at this point in the history
* simplify parsing of password results for 3rd-party charms

* update charm code

* update tls tests for bug

* update libs

* k8s-afy code

* monitor user tests
  • Loading branch information
MiaAltieri authored Feb 27, 2023
1 parent 120bf1d commit cb01f75
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 45 deletions.
4 changes: 2 additions & 2 deletions actions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ get-password:
username:
type: string
description: The username, the default value 'operator'.
Possible values - operator, backup, replication.
Possible values - operator, monitor.
set-password:
description: Change the system user's password used by charm.
It is for internal charm users and SHOULD NOT be used by applications.
params:
username:
type: string
description: The username, the default value 'operator'.
Possible values - operator, backup, replication.
Possible values - operator, monitor.
password:
type: string
description: The password will be auto-generated if this option is not specified.
Expand Down
40 changes: 34 additions & 6 deletions lib/charms/mongodb/v0/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 3
LIBPATCH = 4

# path to store mongodb ketFile
logger = logging.getLogger(__name__)

# List of system usernames needed for correct work on the charm.
CHARM_USERS = ["operator"]
CHARM_USERS = ["operator", "backup", "monitor"]


@dataclass
Expand Down Expand Up @@ -242,8 +242,8 @@ def add_replset_member(self, hostname: str) -> None:
self.client.admin.command("replSetReconfig", rs_config["config"])

@retry(
stop=stop_after_attempt(3),
wait=wait_fixed(5),
stop=stop_after_attempt(20),
wait=wait_fixed(3),
reraise=True,
before=before_log(logger, logging.DEBUG),
)
Expand All @@ -263,8 +263,7 @@ def remove_replset_member(self, hostname: str) -> None:
# before giving up.
raise NotReadyError

# avoid downtime we need to reelect new primary
# if removable member is the primary.
# avoid downtime we need to reelect new primary if removable member is the primary.
logger.debug("primary: %r", self._is_primary(rs_status, hostname))
if self._is_primary(rs_status, hostname):
self.client.admin.command("replSetStepDown", {"stepDownSecs": "60"})
Expand Down Expand Up @@ -306,6 +305,23 @@ def set_user_password(self, username, password: str):
pwd=password,
)

def create_role(self, role_name: str, privileges: dict, roles: dict = []):
"""Creates a new role.
Args:
role_name: name of the role to be added.
privileges: privledges to be associated with the role.
roles: List of roles from which this role inherits privileges.
"""
try:
self.client.admin.command(
"createRole", role_name, privileges=[privileges], roles=roles
)
except OperationFailure as e:
if not e.code == 51002: # Role already exists
logger.error("Cannot add role. error=%r", e)
raise e

@staticmethod
def _get_roles(config: MongoDBConfiguration) -> List[dict]:
"""Generate roles List."""
Expand All @@ -315,6 +331,18 @@ 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"},
{"db": "admin", "role": "clusterMonitor"},
{"db": "admin", "role": "restore"},
{"db": "admin", "role": "pbmAnyAction"},
],
"default": [
{"role": "readWrite", "db": config.database},
],
Expand Down
203 changes: 177 additions & 26 deletions lib/charms/tls_certificates_interface/v1/tls_certificates.py

Large diffs are not rendered by default.

51 changes: 48 additions & 3 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@

logger = logging.getLogger(__name__)
PEER = "database-peers"
MONITOR_PRIVILEGES = {
"resource": {"db": "", "collection": ""},
"actions": ["listIndexes", "listCollections", "dbStats", "dbHash", "collStats", "find"],
}


class MongoDBCharm(CharmBase):
Expand Down Expand Up @@ -159,6 +163,7 @@ def _on_start(self, event) -> None:
direct_mongo.init_replset()
logger.info("User initialization")
self._init_user(container)
self._init_monitor_user()
logger.info("Reconcile relations")
self.client_relations.oversee_users(None, event)
except ExecError as e:
Expand Down Expand Up @@ -411,6 +416,46 @@ def _init_user(self, container: Container) -> None:

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"

@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())

peers = self.model.get_relation(PEER)
hosts = [self.get_hostname_by_unit(self.unit.name)] + [
self.get_hostname_by_unit(unit.name) for unit in peers.units
]

return MongoDBConfiguration(
replset=self.app.name,
database="",
username="monitor",
password=self.get_secret("app", "monitor_password"),
hosts=set(hosts),
roles={"monitor"},
tls_external=self.tls.get_tls_files("unit") is not None,
tls_internal=self.tls.get_tls_files("app") is not None,
)

def _on_get_password(self, event: ActionEvent) -> None:
"""Returns the password for the user as an action response."""
username = "operator"
Expand All @@ -421,7 +466,7 @@ def _on_get_password(self, event: ActionEvent) -> None:
f"The action can be run only for users used by the charm: {CHARM_USERS} not {username}"
)
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: ActionEvent) -> None:
"""Set the password for the specified user."""
Expand All @@ -445,7 +490,7 @@ def _on_set_password(self, event: ActionEvent) -> None:

if new_password == self.get_secret("app", f"{username}_password"):
event.log("The old and new passwords are equal.")
event.set_results({f"{username}-password": new_password})
event.set_results({"password": new_password})
return

with MongoDBConnection(self.mongodb_config) as mongo:
Expand All @@ -460,7 +505,7 @@ def _on_set_password(self, event: ActionEvent) -> None:
event.fail(f"Failed changing the password: {e}")
return
self.set_secret("app", f"{username}_password", new_password)
event.set_results({f"{username}-password": new_password})
event.set_results({"password": new_password})


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/ha_tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ async def get_other_mongodb_direct_client(ops_test: OpsTest, app_name: str) -> M
unit = ops_test.model.applications[app_name].units[0]
action = await unit.run_action("get-password")
action = await action.wait()
password = action.results["operator-password"]
password = action.results["password"]
status = await ops_test.model.get_status()
address = status["applications"][app_name]["units"][unit.name]["address"]

Expand Down
8 changes: 5 additions & 3 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,17 @@ async def get_address_of_unit(ops_test: OpsTest, unit_id: int) -> str:
return status["applications"][APP_NAME]["units"][f"{APP_NAME}/{unit_id}"]["address"]


async def get_password(ops_test: OpsTest, unit_id: int) -> str:
async def get_password(ops_test: OpsTest, unit_id: int, username="operator") -> str:
"""Use the charm action to retrieve the password from provided unit.
Returns:
String with the password stored on the peer relation databag.
"""
action = await ops_test.model.units.get(f"{APP_NAME}/{unit_id}").run_action("get-password")
action = await ops_test.model.units.get(f"{APP_NAME}/{unit_id}").run_action(
"get-password", **{"username": username}
)
action = await action.wait()
return action.results["operator-password"]
return action.results["password"]


async def get_mongo_cmd(ops_test: OpsTest, unit_name: str):
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
generate_collection_id,
get_address_of_unit,
get_leader_id,
get_mongo_cmd,
get_password,
primary_host,
run_mongo_op,
secondary_mongo_uris_with_sync_delay,
Expand Down Expand Up @@ -95,6 +97,22 @@ async def test_application_primary(ops_test: OpsTest):
), "primary not leader on deployment"


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, unit_id=0, username="monitor")
addresses = [await get_address_of_unit(ops_test, unit_id) for unit_id in UNIT_IDS]
hosts = ",".join(addresses)
mongo_uri = f"mongodb://monitor:{password}@{hosts}/admin?"

admin_mongod_cmd = await get_mongo_cmd(ops_test, unit.name)
admin_mongod_cmd += f" {mongo_uri} --eval 'rs.conf()'"
complete_command = f"ssh --container mongod {unit.name} {admin_mongod_cmd}"

return_code, _, stderr = await ops_test.juju(*complete_command.split())
assert return_code == 0, f"command rs.conf() on monitor user does not work, error: {stderr}"


async def test_scale_up(ops_test: OpsTest):
"""Tests juju add-unit functionality.
Expand Down
5 changes: 1 addition & 4 deletions tests/integration/tls_tests/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None:

config = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
await ops_test.model.deploy(
TLS_CERTIFICATES_APP_NAME,
channel="beta",
config=config,
series="focal",
TLS_CERTIFICATES_APP_NAME, channel="beta", config=config, series="jammy"
)
await ops_test.model.wait_for_idle(
apps=[TLS_CERTIFICATES_APP_NAME], status="active", timeout=1000
Expand Down

0 comments on commit cb01f75

Please sign in to comment.