Skip to content

Commit

Permalink
[DPE-4494] Revision checking (#419)
Browse files Browse the repository at this point in the history
## Issue
Currently Charmed MongoDB supports creating clusters with different
versions of charm components, i.e. a shard with revision 88 and a config
server with revision 100. this can cause unexpected issues.

## Solution
Prevent the charm from doing this by adding a revision check on relation
joined and reporting inconsistencies in update status

## Future PRs
To keep the size of this PR down we will do integration and unit tests
in the subsequent PRs
If Pedro and other team member wish to use it we can create this as a
lib in the data platform repo

---------

Co-authored-by: Mehdi Bendriss <[email protected]>
  • Loading branch information
MiaAltieri and Mehdi-Bendriss authored Jun 17, 2024
1 parent eb9c9e0 commit 5b18c49
Show file tree
Hide file tree
Showing 8 changed files with 445 additions and 15 deletions.
66 changes: 66 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,19 @@
update_mongod_service,
)
from upgrades.mongodb_upgrade import MongoDBUpgrade
from version_check import CrossAppVersionChecker, NoVersionError, get_charm_revision

AUTH_FAILED_CODE = 18
UNAUTHORISED_CODE = 13
TLS_CANNOT_FIND_PRIMARY = 133

LOCALLY_BUIT_CHARM_WARNING = (
"WARNING: deploying a local charm, cannot check revision across components."
)
INTEGRATED_TO_LOCALLY_BUIT_CHARM_WARNING = (
"WARNING: integrated to a local charm, cannot check revision across components."
)

logger = logging.getLogger(__name__)

APP_SCOPE = Config.Relations.APP_SCOPE
Expand Down Expand Up @@ -136,6 +144,18 @@ def __init__(self, *args):
self.legacy_client_relations = MongoDBLegacyProvider(self)
self.tls = MongoDBTLS(self, Config.Relations.PEERS, substrate=Config.SUBSTRATE)
self.backups = MongoDBBackups(self)

# TODO future PR - support pinning mongos version
self.version_checker = CrossAppVersionChecker(
self,
# TODO future PR add ops model revision variable:
# https://github.com/canonical/operator/issues/1255
version=get_charm_revision(self.unit),
relations_to_check=[
Config.Relations.SHARDING_RELATIONS_NAME,
Config.Relations.CONFIG_SERVER_RELATIONS_NAME,
],
)
self.upgrade = MongoDBUpgrade(self)
self.config_server = ShardingProvider(self)
self.cluster = ClusterProvider(self)
Expand Down Expand Up @@ -1478,6 +1498,8 @@ def get_invalid_integration_status(self) -> Optional[StatusBase]:
"Relation to s3-integrator is not supported, config role must be config-server"
)

return self.get_cluster_mismatched_revision_status()

def get_statuses(self) -> Tuple:
"""Retrieves statuses for the different processes running inside the unit."""
mongodb_status = build_unit_status(self.mongodb_config, self.unit_ip(self.unit))
Expand Down Expand Up @@ -1558,12 +1580,56 @@ def is_relation_feasible(self, rel_interface) -> bool:
)
return False

if revision_mismatch_status := self.get_cluster_mismatched_revision_status():
self.unit.status = revision_mismatch_status
return False

return True

def is_sharding_component(self) -> bool:
"""Returns true if charm is running as a sharded component."""
return self.is_role(Config.Role.SHARD) or self.is_role(Config.Role.CONFIG_SERVER)

def get_cluster_mismatched_revision_status(self) -> Optional[StatusBase]:
"""Returns a Status if the cluster has mismatched revisions."""
if self.version_checker.is_local_charm(self.app.name):
logger.warning(LOCALLY_BUIT_CHARM_WARNING)
return

if self.version_checker.is_integrated_to_locally_built_charm():
logger.warning(INTEGRATED_TO_LOCALLY_BUIT_CHARM_WARNING)
return

# check for invalid versions in sharding integrations, i.e. a shard running on
# revision 88 and a config-server running on revision 110
current_charms_version = get_charm_revision(self.unit)
try:
if self.version_checker.are_related_apps_valid():
return
except NoVersionError as e:
# relations to shards/config-server are expected to provide a version number. If they
# do not, it is because they are from an earlier charm revision, i.e. pre-revison X.
# TODO once charm is published, determine which revision X is.
logger.debug(e)
if self.is_role(Config.Role.SHARD):
return BlockedStatus(
f"Charm revision ({current_charms_version}) is not up-to date with config-server."
)

if self.is_role(Config.Role.SHARD):
config_server_revision = self.version_checker.get_version_of_related_app(
self.get_config_server_name()
)
return BlockedStatus(
f"Charm revision ({current_charms_version}) is not up-to date with config-server ({config_server_revision})."
)

if self.is_role(Config.Role.CONFIG_SERVER):
# TODO Future PR add handling for integrated mongos charms
return WaitingStatus(
f"Waiting for shards to upgrade/downgrade to revision {current_charms_version}."
)

def get_config_server_name(self) -> Optional[str]:
"""Returns the name of the Juju Application that the shard is using as a config server."""
if not self.is_role(Config.Role.SHARD):
Expand Down
244 changes: 244 additions & 0 deletions src/version_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Class used to determine if a relevant version attribute across related applications are valid.
There are many potential applications for this. Here are a few examples:
1. in a sharded cluster where is is important that the shards and cluster manager have the same
components.
2. kafka connect and kafka broker apps working together and needing to have the same ubnderlying
version.
How to use:
1. in src/charm.py of requirer + provider
in constructor [REQUIRED]:
self.version_checker = self.CrossAppVersionChecker(
self,
version=x, # can be a revision of a charm, version of a snap, version of a workload, etc
relations_to_check=[x,y,z],
# only use if the version doesn't not need to exactly match our current version
version_validity_range={"x": "<a,>b"})
in update status hook [OPTIONAL]:
if not self.version_checker.are_related_apps_valid():
logger.debug(
"Warning relational version check failed, these relations have mismatched versions",
"%s",
self.version_checker(self.version_checker.get_invalid_versions())
)
# can set status, instruct user to change
2. other areas of the charm (i.e. joined events, action events, etc) [OPTIONAL]:
if not self.charm.version_checker.are_related_apps_valid():
# do something - i.e. fail event or log message
3. in upgrade handler of requirer + provider [REQUIRED]:
if [last unit to upgrade]:
self.charm.version.set_version_across_all_relations()
"""
import logging
from typing import Dict, List, Optional, Tuple

from ops.charm import CharmBase
from ops.framework import Object
from ops.model import Unit

logger = logging.getLogger(__name__)

VERSION_CONST = "version"
DEPLOYMENT_TYPE = "deployment"
PREFIX_DIR = "/var/lib/juju/agents/"
LOCAL_BUILT_CHARM_PREFIX = "local"


def get_charm_revision(unit: Unit) -> int:
"""Returns the charm revision.
TODO: Keep this until ops framework supports: https://github.com/canonical/operator/issues/1255
"""
file_path = f"{PREFIX_DIR}unit-{unit.name.replace('/','-')}/charm/.juju-charm"
with open(file_path) as f:
charm_path = f.read().rstrip()

# revision of charm in a locally built chamr is unreliable:
# https://chat.canonical.com/canonical/pl/ro9935ayxbyyxn9hn6opy4f4xw
if charm_path.split(":")[0] == LOCAL_BUILT_CHARM_PREFIX:
logger.debug("Charm is locally built. Cannot determine revision number.")
return 0

# charm_path is of the format ch:amd64/jammy/<charm-name>-<revision number>
revision = charm_path.split("-")[-1]
return int(revision)


class CrossAppVersionChecker(Object):
"""Verifies versions across multiple integrated applications."""

def __init__(
self,
charm: CharmBase,
version: int,
relations_to_check: List[str],
version_validity_range: Optional[Dict] = None,
) -> None:
"""Constructor for CrossAppVersionChecker.
Args:
charm: charm to inherit from
version: (int), the current version of the desired attribute of the charm
relations_to_check: (List), a list of relations who should have compatible versions
with the current charm
version_validity_range: (Optional Dict), a list of ranges for valid version ranges.
If not provided it is assumed that relations on the provided interface must have
the same version.
"""
super().__init__(charm, None)
self.charm = charm
# Future PR: upgrade this to a dictionary name versions
self.version = version
self.relations_to_check = relations_to_check

for rel in relations_to_check:
self.framework.observe(
charm.on[rel].relation_created,
self.set_version_on_relation_created,
)

# this feature has yet to be implemented, MongoDB does not need it and it is unclear if
# this will be extended to other charms. If this code is extended to other charms and
# there is a valid usecase we will use the `version_validity_range` variable in the
# function `get_invalid_versions`
self.version_validity_range = version_validity_range

def get_invalid_versions(self) -> List[Tuple[str, int]]:
"""Returns a list of (app name, version number) pairs, if the version number mismatches.
Mismatches are decided based on version_validity_range, if version_validity_range is not
provided, then the mismatch is expected to match this current app's version number.
Raises:
NoVersionError.
"""
try:
invalid_relations = []
for relation_name in self.relations_to_check:
for relation in self.charm.model.relations[relation_name]:
related_version = relation.data[relation.app][VERSION_CONST]
if int(related_version) != self.version:
invalid_relations.append((relation.app.name, related_version))
except KeyError:
raise NoVersionError(f"Expected {relation.app.name} to have version info.")

return invalid_relations

def get_version_of_related_app(self, related_app_name: str) -> int:
"""Returns a int for the version of the related app.
Raises:
NoVersionError.
"""
try:
for relation_name in self.relations_to_check:
for rel in self.charm.model.relations[relation_name]:
if rel.app.name == related_app_name:
return int(rel.data[rel.app][VERSION_CONST])
except KeyError:
pass

raise NoVersionError(f"Expected {related_app_name} to have version info.")

def get_deployment_prefix(self) -> str:
"""Returns the deployment prefix, indicating if the charm is locally deployred or not.
TODO: Keep this until ops framework supports:
https://github.com/canonical/operator/issues/1255
"""
file_path = f"{PREFIX_DIR}/unit-{self.charm.unit.name.replace('/','-')}/charm/.juju-charm"
with open(file_path) as f:
charm_path = f.read().rstrip()

return charm_path.split(":")[0]

def are_related_apps_valid(self) -> bool:
"""Returns True if a related app has a version that's incompatible with the current app.
Raises:
NoVersionError.
"""
return self.get_invalid_versions() == []

def set_version_across_all_relations(self) -> None:
"""Sets the version number across all related apps, prvided by relations_to_check."""
if not self.charm.unit.is_leader():
return

for relation_name in self.relations_to_check:
for rel in self.charm.model.relations[relation_name]:
rel.data[self.charm.model.app][VERSION_CONST] = str(self.version)
rel.data[self.charm.model.app][DEPLOYMENT_TYPE] = str(self.get_deployment_prefix())

def set_version_on_related_app(self, relation_name: str, related_app_name: str) -> None:
"""Sets the version number across for a specified relation on a specified app."""
if not self.charm.unit.is_leader():
return

for rel in self.charm.model.relations[relation_name]:
if rel.app.name == related_app_name:
rel.data[self.charm.model.app][VERSION_CONST] = str(self.version)
rel.data[self.charm.model.app][DEPLOYMENT_TYPE] = str(self.get_deployment_prefix())

def set_version_on_relation_created(self, event) -> None:
"""Shares the charm's revision to the newly integrated application.
Raises:
RelationInvalidError
"""
if event.relation.name not in self.relations_to_check:
raise RelationInvalidError(
f"Provided relation: {event.relation.name} not in self.relations_to_check."
)

self.set_version_on_related_app(event.relation.name, event.app.name)

def is_integrated_to_locally_built_charm(self) -> bool:
"""Returns a boolean value indicating whether the charm is integrated is a local charm.
NOTE: this function ONLY checks relations on the provided interfaces in
relations_to_check.
"""
for relation_name in self.relations_to_check:
for rel in self.charm.model.relations[relation_name]:
if rel.data[rel.app][DEPLOYMENT_TYPE] == LOCAL_BUILT_CHARM_PREFIX:
return True

return False

def is_local_charm(self, app_name: str) -> bool:
"""Returns a boolean value indicating whether the provided app is a local charm."""
if self.charm.app.name == app_name:
return self.get_deployment_prefix() == LOCAL_BUILT_CHARM_PREFIX

try:
for relation_name in self.relations_to_check:
for rel in self.charm.model.relations[relation_name]:
if rel.app.name == app_name:
return rel.data[rel.app][DEPLOYMENT_TYPE] == LOCAL_BUILT_CHARM_PREFIX
except KeyError:
pass

raise NoVersionError(f"Expected {app_name} to have version info.")


class CrossAppVersionCheckerError(Exception):
"""Parent class for errors raised in CrossAppVersionChecker class."""


class RelationInvalidError(CrossAppVersionCheckerError):
"""Raised if a relation is not in the provided set of relations to check."""


class NoVersionError(CrossAppVersionCheckerError):
"""Raised if an application does not contain any version information."""
Loading

0 comments on commit 5b18c49

Please sign in to comment.