Skip to content

Commit

Permalink
[DPE-2992] Part 1 - Provide Mongos with keyfile (#309)
Browse files Browse the repository at this point in the history
## Issue
DPE-2992 is to start the mongos subordinate charm with the provided
config server. In order for the config server and for mongos to be
connected they both must have the same keyfile

## Solution
Provide that key file

## Follow up PRs
1. A follow up PR on mongos charm that uses this library
2. Updating the library to actually start the mongos service with the
information of the config server
3. Integration tests

## Testing
```
juju deploy ./*charm --config role="config-server"
juju deploy application
juju deploy mongos
juju integrate application mongos
juju integrate mongos:cluster mongodb:cluster
juju ssh mongos
sudo cat KEY_FILE_PATH
```
  • Loading branch information
MiaAltieri authored Nov 28, 2023
1 parent 2304e13 commit 01a2aa7
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 21 deletions.
150 changes: 150 additions & 0 deletions lib/charms/mongodb/v0/config_server_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.

"""In this class, we manage relations between config-servers and shards.
This class handles the sharing of secrets between sharded components, adding shards, and removing
shards.
"""
import logging

from ops.charm import CharmBase, EventBase
from ops.framework import Object
from ops.model import WaitingStatus

from config import Config

logger = logging.getLogger(__name__)
KEYFILE_KEY = "key-file"
KEY_FILE = "keyFile"
HOSTS_KEY = "host"
CONFIG_SERVER_URI_KEY = "config-server-uri"

# The unique Charmhub library identifier, never change it
LIBID = "58ad1ccca4974932ba22b97781b9b2a0"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

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


class ClusterProvider(Object):
"""Manage relations between the config server and mongos router on the config-server side."""

def __init__(
self, charm: CharmBase, relation_name: str = Config.Relations.CLUSTER_RELATIONS_NAME
) -> None:
"""Constructor for ShardingProvider object."""
self.relation_name = relation_name
self.charm = charm

super().__init__(charm, self.relation_name)
self.framework.observe(
charm.on[self.relation_name].relation_joined, self._on_relation_joined
)

# TODO Future PRs handle scale down

def pass_hook_checks(self, event: EventBase) -> bool:
"""Runs the pre-hooks checks for ClusterProvider, returns True if all pass."""
if not self.charm.is_role(Config.Role.CONFIG_SERVER):
logger.info(
"Skipping %s. ShardingProvider is only be executed by config-server", type(event)
)
return False

if not self.charm.unit.is_leader():
return False

if not self.charm.db_initialised:
logger.info("Deferring %s. db is not initialised.", type(event))
event.defer()
return False

return True

def _on_relation_joined(self, event):
"""Handles providing mongos with KeyFile and hosts."""
if not self.pass_hook_checks(event):
logger.info("Skipping relation joined event: hook checks did not pass")
return

# TODO Future PR, provide URI
# TODO Future PR, use secrets
self._update_relation_data(
event.relation.id,
{
KEYFILE_KEY: self.charm.get_secret(
Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME
),
},
)

def _update_relation_data(self, relation_id: int, data: dict) -> None:
"""Updates a set of key-value pairs in the relation.
This function writes in the application data bag, therefore, only the leader unit can call
it.
Args:
relation_id: the identifier for a particular relation.
data: dict containing the key-value pairs
that should be updated in the relation.
"""
if self.charm.unit.is_leader():
relation = self.charm.model.get_relation(self.relation_name, relation_id)
if relation:
relation.data[self.charm.model.app].update(data)


class ClusterRequirer(Object):
"""Manage relations between the config server and mongos router on the mongos side."""

def __init__(
self, charm: CharmBase, relation_name: str = Config.Relations.CLUSTER_RELATIONS_NAME
) -> None:
"""Constructor for ShardingProvider object."""
self.relation_name = relation_name
self.charm = charm

super().__init__(charm, self.relation_name)
self.framework.observe(
charm.on[self.relation_name].relation_changed, self._on_relation_changed
)
# TODO Future PRs handle scale down

def _on_relation_changed(self, event):
"""Starts/restarts monogs with config server information."""
relation_data = event.relation.data[event.app]
if not relation_data.get(KEYFILE_KEY):
event.defer()
self.charm.unit.status = WaitingStatus("Waiting for secrets from config-server")
return

self.update_keyfile(key_file_contents=relation_data.get(KEYFILE_KEY))

# TODO: Follow up PR. Start mongos with the config-server URI
# TODO: Follow up PR. Add a user for mongos once it has been started

def update_keyfile(self, key_file_contents: str) -> None:
"""Updates keyfile on all units."""
# keyfile is set by leader in application data, application data does not necessarily
# match what is on the machine.
current_key_file = self.charm.get_keyfile_contents()
if not key_file_contents or key_file_contents == current_key_file:
return

# put keyfile on the machine with appropriate permissions
self.charm.push_file_to_unit(
parent_dir=Config.MONGOD_CONF_DIR, file_name=KEY_FILE, file_contents=key_file_contents
)

if not self.charm.unit.is_leader():
return

self.charm.set_secret(
Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME, key_file_contents
)
20 changes: 20 additions & 0 deletions lib/charms/mongodb/v1/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,23 @@ def process_pbm_status(pbm_status: str) -> StatusBase:
return WaitingStatus("waiting to sync s3 configurations.")

return ActiveStatus()


def add_args_to_env(var: str, args: str):
"""Adds the provided arguments to the environment as the provided variable."""
with open(Config.ENV_VAR_PATH, "r") as env_var_file:
env_vars = env_var_file.readlines()

args_added = False
for index, line in enumerate(env_vars):
if var in line:
args_added = True
env_vars[index] = f"{var}={args}"

# if it is the first time adding these args to the file - will will need to append them to the
# file
if not args_added:
env_vars.append(f"{var}={args}")

with open(Config.ENV_VAR_PATH, "w") as service_file:
service_file.writelines(env_vars)
2 changes: 2 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ provides:
interface: cos_agent
config-server:
interface: shards
cluster:
interface: config-server

storage:
mongodb:
Expand Down
2 changes: 2 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Dict, List, Optional, Set

from charms.grafana_agent.v0.cos_agent import COSAgentProvider
from charms.mongodb.v0.config_server_interface import ClusterProvider
from charms.mongodb.v0.mongodb import (
MongoDBConfiguration,
MongoDBConnection,
Expand Down Expand Up @@ -124,6 +125,7 @@ def __init__(self, *args):
self.tls = MongoDBTLS(self, Config.Relations.PEERS, substrate=Config.SUBSTRATE)
self.backups = MongoDBBackups(self)
self.config_server = ShardingProvider(self)
self.cluster = ClusterProvider(self)
self.shard = ConfigServerRequirer(self)

# relation events for Prometheus metrics are handled in the MetricsEndpointProvider
Expand Down
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class Relations:
OBSOLETE_RELATIONS_NAME = "obsolete"
SHARDING_RELATIONS_NAME = "sharding"
CONFIG_SERVER_RELATIONS_NAME = "config-server"
CLUSTER_RELATIONS_NAME = "cluster"
APP_SCOPE = "app"
UNIT_SCOPE = "unit"
DB_RELATIONS = [OBSOLETE_RELATIONS_NAME, NAME]
Expand Down
22 changes: 1 addition & 21 deletions src/machine_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging

from charms.mongodb.v0.mongodb import MongoDBConfiguration
from charms.mongodb.v1.helpers import get_mongod_args, get_mongos_args
from charms.mongodb.v1.helpers import add_args_to_env, get_mongod_args, get_mongos_args

from config import Config

Expand All @@ -27,23 +27,3 @@ def update_mongod_service(
if role == Config.Role.CONFIG_SERVER:
mongos_start_args = get_mongos_args(config, snap_install=True)
add_args_to_env("MONGOS_ARGS", mongos_start_args)


def add_args_to_env(var: str, args: str):
"""Adds the provided arguments to the environment as the provided variable."""
with open(Config.ENV_VAR_PATH, "r") as env_var_file:
env_vars = env_var_file.readlines()

args_added = False
for index, line in enumerate(env_vars):
if var in line:
args_added = True
env_vars[index] = f"{var}={args}"

# if it is the first time adding these args to the file - will will need to append them to the
# file
if not args_added:
env_vars.append(f"{var}={args}")

with open(Config.ENV_VAR_PATH, "w") as service_file:
service_file.writelines(env_vars)
55 changes: 55 additions & 0 deletions tests/unit/test_config_server_lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.
import unittest
from unittest.mock import patch

from ops.testing import Harness

from charm import MongodbOperatorCharm

from .helpers import patch_network_get

RELATION_NAME = "s3-credentials"


class TestConfigServerInterface(unittest.TestCase):
@patch_network_get(private_address="1.1.1.1")
def setUp(self):
self.harness = Harness(MongodbOperatorCharm)
self.harness.begin()
self.harness.add_relation("database-peers", "database-peers")
self.harness.set_leader(True)
self.charm = self.harness.charm
self.addCleanup(self.harness.cleanup)

@patch("charm.ClusterProvider._update_relation_data")
def test_on_relation_joined_failed_hook_checks(self, _update_relation_data):
"""Tests that no relation data is set when cluster joining conditions are not met."""

def is_not_config_mock_call(*args):
assert args == ("config-server",)
return False

self.harness.charm.app_peer_data["db_initialised"] = "True"

# fails due to being run on non-config-server
self.harness.charm.is_role = is_not_config_mock_call
relation_id = self.harness.add_relation("cluster", "mongos")
self.harness.add_relation_unit(relation_id, "mongos/0")
_update_relation_data.assert_not_called()

# fails because db has not been initialized
del self.harness.charm.app_peer_data["db_initialised"]

def is_config_mock_call(*args):
assert args == ("config-server",)
return True

self.harness.charm.is_role = is_config_mock_call
self.harness.add_relation_unit(relation_id, "mongos/1")
_update_relation_data.assert_not_called()

# fails because not leader
self.harness.set_leader(False)
self.harness.add_relation_unit(relation_id, "mongos/2")
_update_relation_data.assert_not_called()

0 comments on commit 01a2aa7

Please sign in to comment.