Skip to content

Commit

Permalink
Merge pull request #8 from canonical/start-mongos-daemon
Browse files Browse the repository at this point in the history
[DPE-2992] Start mongos daemon with provided config server
  • Loading branch information
MiaAltieri authored Dec 1, 2023
2 parents 17ddef3 + 76ba878 commit f78ef32
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 43 deletions.
88 changes: 71 additions & 17 deletions lib/charms/mongodb/v0/config_server_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@
This class handles the sharing of secrets between sharded components, adding shards, and removing
shards.
"""
import json
import logging

from charms.mongodb.v1.helpers import add_args_to_env, get_mongos_args
from charms.mongodb.v1.mongos import MongosConnection
from ops.charm import CharmBase, EventBase
from ops.framework import Object
from ops.model import WaitingStatus
from ops.model import ActiveStatus, MaintenanceStatus, 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"
CONFIG_SERVER_DB_KEY = "config-server-db"
MONGOS_SOCKET_URI_FMT = "%2Fvar%2Fsnap%2Fcharmed-mongodb%2Fcommon%2Fvar%2Fmongodb-27018.sock"

# The unique Charmhub library identifier, never change it
LIBID = "58ad1ccca4974932ba22b97781b9b2a0"
Expand All @@ -28,7 +32,7 @@

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


class ClusterProvider(Object):
Expand All @@ -47,6 +51,7 @@ def __init__(
)

# TODO Future PRs handle scale down
# TODO Future PRs handle changing of units/passwords to be propagated to mongos

def pass_hook_checks(self, event: EventBase) -> bool:
"""Runs the pre-hooks checks for ClusterProvider, returns True if all pass."""
Expand All @@ -66,20 +71,22 @@ def pass_hook_checks(self, event: EventBase) -> bool:

return True

def _on_relation_joined(self, event):
def _on_relation_joined(self, event) -> None:
"""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
config_server_db = self.generate_config_server_db()

# 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
),
CONFIG_SERVER_DB_KEY: config_server_db,
},
)

Expand All @@ -99,6 +106,16 @@ def _update_relation_data(self, relation_id: int, data: dict) -> None:
if relation:
relation.data[self.charm.model.app].update(data)

def generate_config_server_db(self) -> str:
"""Generates the config server database for mongos to connect to."""
replica_set_name = self.charm.app.name
hosts = []
for host in self.charm._unit_ips:
hosts.append(f"{host}:{Config.MONGODB_PORT}")

hosts = ",".join(hosts)
return f"{replica_set_name}/{hosts}"


class ClusterRequirer(Object):
"""Manage relations between the config server and mongos router on the mongos side."""
Expand All @@ -116,35 +133,72 @@ def __init__(
)
# TODO Future PRs handle scale down

def _on_relation_changed(self, event):
def _on_relation_changed(self, event) -> None:
"""Starts/restarts monogs with config server information."""
relation_data = event.relation.data[event.app]
if not relation_data.get(KEYFILE_KEY):
if not relation_data.get(KEYFILE_KEY) or not relation_data.get(CONFIG_SERVER_DB_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))
updated_keyfile = self.update_keyfile(key_file_contents=relation_data.get(KEYFILE_KEY))
updated_config = self.update_config_server_db(
config_server_db=relation_data.get(CONFIG_SERVER_DB_KEY)
)

# avoid restarting mongos when possible
if not updated_keyfile and not updated_config and self.is_mongos_running():
return

# mongos is not available until it is using new secrets
logger.info("Restarting mongos with new secrets")
self.charm.unit.status = MaintenanceStatus("starting mongos")
self.charm.restart_mongos_service()

# restart on high loaded databases can be very slow (e.g. up to 10-20 minutes).
if not self.is_mongos_running():
logger.info("mongos has not started, deferring")
self.charm.unit.status = WaitingStatus("Waiting for mongos to start")
event.defer()
return

# 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
self.charm.unit.status = ActiveStatus()

def is_mongos_running(self) -> bool:
"""Returns true if mongos service is running."""
with MongosConnection(None, f"mongodb://{MONGOS_SOCKET_URI_FMT}") as mongo:
return mongo.is_ready

def update_config_server_db(self, config_server_db) -> bool:
"""Updates config server str when necessary."""
if self.charm.config_server_db == config_server_db:
return False

mongos_config = self.charm.mongos_config
mongos_start_args = get_mongos_args(
mongos_config, snap_install=True, config_server_db=config_server_db
)
add_args_to_env("MONGOS_ARGS", mongos_start_args)
self.charm.unit_peer_data["config_server_db"] = json.dumps(config_server_db)
return True

def update_keyfile(self, key_file_contents: str) -> None:
"""Updates keyfile on all units."""
def update_keyfile(self, key_file_contents: str) -> bool:
"""Updates keyfile when necessary."""
# 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
return False

# 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
if self.charm.unit.is_leader():
self.charm.set_secret(
Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME, key_file_contents
)

self.charm.set_secret(
Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME, key_file_contents
)
return True
19 changes: 13 additions & 6 deletions lib/charms/mongodb/v1/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

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

# path to store mongodb ketFile
KEY_FILE = "keyFile"
Expand Down Expand Up @@ -81,24 +81,31 @@ def get_create_user_cmd(config: MongoDBConfiguration, mongo_path=MONGO_SHELL) ->


def get_mongos_args(
config: MongoDBConfiguration,
config,
snap_install: bool = False,
config_server_db: str = None,
) -> str:
"""Returns the arguments used for starting mongos on a config-server side application.
Returns:
A string representing the arguments to be passed to mongos.
"""
# suborinate charm which provides its own config_server_db, should only use unix domain socket
binding_ips = (
f"--bind_ip {MONGODB_COMMON_DIR}/var/mongodb-27018.sock"
if config_server_db
else "--bind_ip_all"
)

# mongos running on the config server communicates through localhost
# use constant for port
config_server_uri = f"{config.replset}/localhost:27017"
config_server_db = config_server_db or f"{config.replset}/localhost:{Config.MONGODB_PORT}"

full_conf_dir = f"{MONGODB_SNAP_DATA_DIR}{CONF_DIR}" if snap_install else CONF_DIR
cmd = [
# mongos on config server side should run on 0.0.0.0 so it can be accessed by other units
# in the sharded cluster
"--bind_ip_all",
f"--configdb {config_server_uri}",
binding_ips,
f"--configdb {config_server_db}",
# config server is already using 27017
f"--port {Config.MONGOS_PORT}",
f"--keyFile={full_conf_dir}/{KEY_FILE}",
Expand Down
72 changes: 62 additions & 10 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
# See LICENSE file for licensing details.
import os
import pwd
import json
from charms.mongodb.v1.helpers import copy_licenses_to_unit, KEY_FILE
from charms.operator_libs_linux.v1 import snap
from pathlib import Path

from charms.mongodb.v0.mongodb_secrets import SecretCache
from typing import Set, List, Optional
from typing import Set, List, Optional, Dict
from charms.mongodb.v0.mongodb_secrets import generate_secret_label
from charms.mongodb.v1.mongos import MongosConfiguration
from charms.mongodb.v0.mongodb import MongoDBConfiguration
from charms.mongodb.v0.config_server_interface import ClusterRequirer
from charms.mongodb.v1.users import (
MongoDBUser,
Expand Down Expand Up @@ -49,10 +49,8 @@ def __init__(self, *args):

self.cluster = ClusterRequirer(self)
self.secrets = SecretCache(self)
# todo future PRs:
# 1. start daemon when relation to config server is made
# 2. add users for related application
# 3. update status indicates missing relations
# 1. add users for related application (to be done on config-server charm side)
# 2. update status indicates missing relations

# BEGIN: hook functions
def _on_install(self, event: InstallEvent) -> None:
Expand Down Expand Up @@ -105,15 +103,14 @@ def _install_snap_packages(self, packages: List[str]) -> None:
raise

@property
def mongos_config(self) -> MongoDBConfiguration:
def mongos_config(self) -> MongosConfiguration:
"""Generates a MongoDBConfiguration object for mongos in the deployment of MongoDB."""
return self._get_mongos_config_for_user(OperatorUser, set("/tmp/mongos.sock"))
return self._get_mongos_config_for_user(OperatorUser, set(Config.MONGOS_SOCKET))

def _get_mongos_config_for_user(
self, user: MongoDBUser, hosts: Set[str], config_server_uri: str
self, user: MongoDBUser, hosts: Set[str]
) -> MongosConfiguration:
return MongosConfiguration(
config_server_uri=config_server_uri,
database=user.get_database_name(),
username=user.get_username(),
password=self.get_secret(APP_SCOPE, user.get_password_key_name()),
Expand Down Expand Up @@ -216,6 +213,61 @@ def push_file_to_unit(self, parent_dir, file_name, file_contents) -> None:
mongodb_user = pwd.getpwnam(MONGO_USER)
os.chown(file_name, mongodb_user.pw_uid, ROOT_USER_GID)

def start_mongos_service(self) -> None:
"""Starts the mongos service.
Raises:
snap.SnapError
"""
snap_cache = snap.SnapCache()
mongodb_snap = snap_cache["charmed-mongodb"]
mongodb_snap.start(services=["mongos"], enable=True)

def stop_mongos_service(self) -> None:
"""Stops the mongos service.
Raises:
snap.SnapError
"""
snap_cache = snap.SnapCache()
mongodb_snap = snap_cache["charmed-mongodb"]
mongodb_snap.stop(services=["mongos"])

def restart_mongos_service(self) -> None:
"""Retarts the mongos service.
Raises:
snap.SnapError
"""
self.stop_mongos_service()
self.start_mongos_service()

@property
def _peers(self) -> Optional[Relation]:
"""Fetch the peer relation.
Returns:
An `ops.model.Relation` object representing the peer relation.
"""
return self.model.get_relation(Config.Relations.PEERS)

@property
def unit_peer_data(self) -> Dict:
"""Unit peer relation data object."""
return self._peers.data[self.unit]

@property
def config_server_db(self):
"""Fetch current the config server database that this unit is connected to.
Returns:
A list of hosts addresses (strings).
"""
if "config_server_db" not in self.unit_peer_data:
return ""

return json.loads(self.unit_peer_data.get("config_server_db"))

# END: helper functions


Expand Down
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Config:
"""Configuration for MongoDB Charm."""

MONGOS_PORT = 27018
MONGOS_SOCKET = "/var/snap/charmed-mongodb/common/var/mongodb-27018.sock"
MONGODB_PORT = 27017
SUBSTRATE = "vm"
ENV_VAR_PATH = "/etc/environment"
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from charms.mongodb.v1.helpers import MONGO_SHELL
from pytest_operator.plugin import OpsTest
import ops

MONGOS_URI = (
"mongodb://%2Fvar%2Fsnap%2Fcharmed-mongodb%2Fcommon%2Fvar%2Fmongodb-27018.sock"
)
MONGOS_APP_NAME = "mongos"


async def generate_mongos_command(ops_test: OpsTest) -> str:
"""Generates a command which verifies mongos is running."""
return f"{MONGO_SHELL} '{MONGOS_URI}' --eval 'ping'"


async def check_mongos(ops_test: OpsTest, unit: ops.model.Unit) -> bool:
"""Returns whether mongos is running on the provided unit."""
mongos_check = await generate_mongos_command(ops_test)
check_tls_cmd = f"exec --unit {unit.name} -- {mongos_check}"
return_code, _, _ = await ops_test.juju(*check_tls_cmd.split())
return return_code == 0
Loading

0 comments on commit f78ef32

Please sign in to comment.