Skip to content

Commit

Permalink
Update datalib interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitry-ratushnyy committed Oct 10, 2023
1 parent caed80e commit 5107301
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 220 deletions.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Testing high availability on a production cluster can be done with:
tox run -e ha-integration -- --model=<model_name>
```

Note if you'd like to test storage re-use in ha-testing, your storage must not be of the type `rootfs`. `rootfs` storage is tied to the machine lifecycle and does not stick around after unit removal. `rootfs` storage is used by default with `tox run -e ha-integration`. To test ha-testing for storage re-use:
Note if you'd like to test storage reuse in ha-testing, your storage must not be of the type `rootfs`. `rootfs` storage is tied to the machine lifecycle and does not stick around after unit removal. `rootfs` storage is used by default with `tox run -e ha-integration`. To test ha-testing for storage reuse:
```shell
juju create-storage-pool mongodb-ebs ebs volume-type=standard # create a storage pool
juju deploy ./*charm --storage mongodb=mongodb-ebs,7G,1 # deploy 1 or more units of application with said storage pool
Expand Down
259 changes: 176 additions & 83 deletions lib/charms/data_platform_libs/v0/data_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent):

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

PYDEPS = ["ops>=2.0.0"]

Expand Down Expand Up @@ -377,12 +377,19 @@ class SecretsIllegalUpdateError(SecretError):
"""Secrets aren't yet available for Juju version used."""


def get_encoded_field(relation, member, field) -> Dict[str, str]:
def get_encoded_field(
relation: Relation, member: Union[Unit, Application], field
) -> Union[str, List[str], Dict[str, str]]:
"""Retrieve and decode an encoded field from relation data."""
return json.loads(relation.data[member].get(field, "{}"))


def set_encoded_field(relation, member, field, value) -> None:
def set_encoded_field(
relation: Relation,
member: Union[Unit, Application],
field: str,
value: Union[str, list, Dict[str, str]],
) -> None:
"""Set an encoded field from relation data."""
relation.data[member].update({field: json.dumps(value)})

Expand All @@ -400,6 +407,15 @@ def diff(event: RelationChangedEvent, bucket: Union[Unit, Application]) -> Diff:
"""
# Retrieve the old data from the data key in the application relation databag.
old_data = get_encoded_field(event.relation, bucket, "data")

if not old_data:
old_data = {}

if not isinstance(old_data, dict):
# We should never get here, added to re-assure pyright
logger.error("Previous databag diff is of a wrong type.")
old_data = {}

# Retrieve the new data from the event relation databag.
new_data = (
{key: value for key, value in event.relation.data[event.app].items() if key != "data"}
Expand All @@ -408,12 +424,16 @@ def diff(event: RelationChangedEvent, bucket: Union[Unit, Application]) -> Diff:
)

# These are the keys that were added to the databag and triggered this event.
added = new_data.keys() - old_data.keys()
added = new_data.keys() - old_data.keys() # pyright: ignore [reportGeneralTypeIssues]
# These are the keys that were removed from the databag and triggered this event.
deleted = old_data.keys() - new_data.keys()
deleted = old_data.keys() - new_data.keys() # pyright: ignore [reportGeneralTypeIssues]
# These are the keys that already existed in the databag,
# but had their values changed.
changed = {key for key in old_data.keys() & new_data.keys() if old_data[key] != new_data[key]}
changed = {
key
for key in old_data.keys() & new_data.keys() # pyright: ignore [reportGeneralTypeIssues]
if old_data[key] != new_data[key] # pyright: ignore [reportGeneralTypeIssues]
}
# Convert the new_data to a serializable format and save it for a next diff check.
set_encoded_field(event.relation, bucket, "data", new_data)

Expand Down Expand Up @@ -592,6 +612,13 @@ def _fetch_specific_relation_data(
"""Fetch data available (directily or indirectly -- i.e. secrets) from the relation."""
raise NotImplementedError

@abstractmethod
def _fetch_my_specific_relation_data(
self, relation, fields: Optional[List[str]]
) -> Dict[str, str]:
"""Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
raise NotImplementedError

# Internal helper methods

@staticmethod
Expand Down Expand Up @@ -658,6 +685,22 @@ def _group_secret_fields(secret_fields: List[str]) -> Dict[SecretGroup, List[str
secret_fieldnames_grouped.setdefault(SecretGroup.EXTRA, []).append(key)
return secret_fieldnames_grouped

def _retrieve_group_secret_contents(
self,
relation_id,
group: SecretGroup,
secret_fields: Optional[Union[Set[str], List[str]]] = None,
) -> Dict[str, str]:
"""Helper function to retrieve collective, requested contents of a secret."""
if not secret_fields:
secret_fields = []

if (secret := self._get_relation_secret(relation_id, group)) and (
secret_data := secret.get_content()
):
return {k: v for k, v in secret_data.items() if k in secret_fields}
return {}

@juju_secrets_only
def _get_relation_secret_data(
self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None
Expand All @@ -667,6 +710,58 @@ def _get_relation_secret_data(
if secret:
return secret.get_content()

def _fetch_relation_data_without_secrets(
self, app: Application, relation, fields: Optional[List[str]]
) -> dict:
if fields:
return {k: relation.data[app].get(k) for k in fields}
else:
return relation.data[app]

def _fetch_relation_data_with_secrets(
self,
app: Application,
req_secret_fields: Optional[List[str]],
relation,
fields: Optional[List[str]] = None,
) -> Dict[str, str]:
result = {}

normal_fields = fields
if not normal_fields:
normal_fields = list(relation.data[app].keys())

if req_secret_fields and self.secrets_enabled:
if fields:
# Processing from what was requested
normal_fields = set(fields) - set(req_secret_fields)
secret_fields = set(fields) - set(normal_fields)

secret_fieldnames_grouped = self._group_secret_fields(list(secret_fields))

for group in secret_fieldnames_grouped:
if contents := self._retrieve_group_secret_contents(
relation.id, group, secret_fields
):
result.update(contents)
else:
# If it wasn't found as a secret, let's give it a 2nd chance as "normal" field
normal_fields |= set(secret_fieldnames_grouped[group])
else:
# Processing from what is given, i.e. retrieving all
normal_fields = [
f for f in relation.data[app].keys() if not self._is_secret_field(f)
]
secret_fields = [f for f in relation.data[app].keys() if self._is_secret_field(f)]
for group in SecretGroup:
result.update(
self._retrieve_group_secret_contents(relation.id, group, req_secret_fields)
)

# Processing "normal" fields. May include leftover from what we couldn't retrieve as a secret.
result.update({k: relation.data[app].get(k) for k in normal_fields})
return result

# Public methods

def get_relation(self, relation_name, relation_id) -> Relation:
Expand Down Expand Up @@ -716,6 +811,50 @@ def fetch_relation_data(
data[relation.id] = self._fetch_specific_relation_data(relation, fields)
return data

def fetch_relation_field(
self, relation_id: int, field: str, relation_name: Optional[str] = None
) -> Optional[str]:
"""Get a single field from the relation data."""
return (
self.fetch_relation_data([relation_id], [field], relation_name)
.get(relation_id, {})
.get(field)
)

def fetch_my_relation_data(
self,
relation_ids: Optional[List[int]] = None,
fields: Optional[List[str]] = None,
relation_name: Optional[str] = None,
):
"""Fetch data of the 'owner' (or 'this app') side of the relation."""
if not relation_name:
relation_name = self.relation_name

relations = []
if relation_ids:
relations = [
self.get_relation(relation_name, relation_id) for relation_id in relation_ids
]
else:
relations = self.relations

data = {}
for relation in relations:
if not relation_ids or (relation_ids and relation.id in relation_ids):
data[relation.id] = self._fetch_my_specific_relation_data(relation, fields)
return data

def fetch_my_relation_field(
self, relation_id: int, field: str, relation_name: Optional[str] = None
) -> Optional[str]:
"""Get a single field from the relation data -- owner side."""
return (
self.fetch_my_relation_data([relation_id], [field], relation_name)
.get(relation_id, {})
.get(field)
)

# Public methods - mandatory override

@abstractmethod
Expand Down Expand Up @@ -823,18 +962,32 @@ def _get_relation_secret(
if secret_uri := relation.data[self.local_app].get(secret_field):
return self.secrets.get(label, secret_uri)

def _fetch_specific_relation_data(self, relation, fields: Optional[List[str]]) -> dict:
def _fetch_specific_relation_data(
self, relation: Relation, fields: Optional[List[str]]
) -> dict:
"""Fetching relation data for Provides.
NOTE: Since all secret fields are in the Requires side of the databag, we don't need to worry about that
NOTE: Since all secret fields are in the Provides side of the databag, we don't need to worry about that
"""
if not relation.app:
return {}

if fields:
return {k: relation.data[relation.app].get(k) for k in fields}
else:
return relation.data[relation.app]
return self._fetch_relation_data_without_secrets(relation.app, relation, fields)

def _fetch_my_specific_relation_data(
self, relation: Relation, fields: Optional[List[str]]
) -> dict:
"""Fetching our own relation data."""
secret_fields = None
if relation.app:
secret_fields = get_encoded_field(relation, relation.app, REQ_SECRET_FIELDS)

return self._fetch_relation_data_with_secrets(
self.local_app,
secret_fields if isinstance(secret_fields, list) else None,
relation,
fields,
)

# Public methods -- mandatory overrides

Expand All @@ -843,7 +996,10 @@ def update_relation_data(self, relation_id: int, fields: Dict[str, str]) -> None
"""Set values for fields not caring whether it's a secret or not."""
relation = self.get_relation(self.relation_name, relation_id)

relation_secret_fields = get_encoded_field(relation, relation.app, REQ_SECRET_FIELDS)
if relation.app:
relation_secret_fields = get_encoded_field(relation, relation.app, REQ_SECRET_FIELDS)
else:
relation_secret_fields = []

normal_fields = list(fields)
if relation_secret_fields and self.secrets_enabled:
Expand Down Expand Up @@ -1021,22 +1177,6 @@ def is_resource_created(self, relation_id: Optional[int] = None) -> bool:
else False
)

def _retrieve_group_secret_contents(
self,
relation_id,
group: SecretGroup,
secret_fields: Optional[Union[Set[str], List[str]]] = None,
) -> Dict[str, str]:
"""Helper function to retrieve collective, requested contents of a secret."""
if not secret_fields:
secret_fields = []

if (secret := self._get_relation_secret(relation_id, group)) and (
secret_data := secret.get_content()
):
return {k: v for k, v in secret_data.items() if k in secret_fields}
return {}

# Event handlers

def _on_relation_created_event(self, event: RelationCreatedEvent) -> None:
Expand Down Expand Up @@ -1070,49 +1210,14 @@ def _get_relation_secret(
def _fetch_specific_relation_data(
self, relation, fields: Optional[List[str]] = None
) -> Dict[str, str]:
if not relation.app:
return {}

result = {}

normal_fields = fields
if not normal_fields:
normal_fields = list(relation.data[relation.app].keys())

if self.secret_fields and self.secrets_enabled:
if fields:
# Processing from what was requested
normal_fields = set(fields) - set(self.secret_fields)
secret_fields = set(fields) - set(normal_fields)

secret_fieldnames_grouped = self._group_secret_fields(list(secret_fields))

for group in secret_fieldnames_grouped:
if contents := self._retrieve_group_secret_contents(
relation.id, group, secret_fields
):
result.update(contents)
else:
# If it wasn't found as a secret, let's give it a 2nd chance as "normal" field
normal_fields |= set(secret_fieldnames_grouped[group])
else:
# Processing from what is given, i.e. retrieving all
normal_fields = [
f for f in relation.data[relation.app].keys() if not self._is_secret_field(f)
]
secret_fields = [
f for f in relation.data[relation.app].keys() if self._is_secret_field(f)
]
for group in SecretGroup:
result.update(
self._retrieve_group_secret_contents(
relation.id, group, self.secret_fields
)
)
"""Fetching Requires data -- that may include secrets."""
return self._fetch_relation_data_with_secrets(
relation.app, self.secret_fields, relation, fields
)

# Processing "normal" fields. May include leftover from what we couldn't retrieve as a secret.
result.update({k: relation.data[relation.app].get(k) for k in normal_fields})
return result
def _fetch_my_specific_relation_data(self, relation, fields: Optional[List[str]]) -> dict:
"""Fetching our own relation data."""
return self._fetch_relation_data_without_secrets(self.local_app, relation, fields)

# Public methods -- mandatory overrides

Expand All @@ -1135,18 +1240,6 @@ def update_relation_data(self, relation_id: int, data: dict) -> None:
if relation:
relation.data[self.local_app].update(data)

# "Native" public methods

def fetch_relation_field(
self, relation_id: int, field: str, relation_name: Optional[str] = None
) -> Optional[str]:
"""Get a single field from the relation data."""
return (
self.fetch_relation_data([relation_id], [field], relation_name)
.get(relation_id, {})
.get(field)
)


# General events

Expand Down
2 changes: 1 addition & 1 deletion lib/charms/mongodb/v0/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ def create_role(self, role_name: str, privileges: dict, roles: dict = []):
Args:
role_name: name of the role to be added.
privileges: privledges to be associated with the role.
privileges: privileges to be associated with the role.
roles: List of roles from which this role inherits privileges.
"""
try:
Expand Down
4 changes: 2 additions & 2 deletions lib/charms/mongodb/v0/mongodb_backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ def _try_to_restore(self, backup_id: str) -> None:
If PBM is resyncing, the function will retry to create backup
(up to BACKUP_RESTORE_MAX_ATTEMPTS times) with BACKUP_RESTORE_ATTEMPT_COOLDOWN
time between attepts.
time between attempts.
If PMB returen any other error, the function will raise RestoreError.
"""
Expand Down Expand Up @@ -541,7 +541,7 @@ def _try_to_backup(self):
If PBM is resyncing, the function will retry to create backup
(up to BACKUP_RESTORE_MAX_ATTEMPTS times)
with BACKUP_RESTORE_ATTEMPT_COOLDOWN time between attepts.
with BACKUP_RESTORE_ATTEMPT_COOLDOWN time between attempts.
If PMB returen any other error, the function will raise BackupError.
"""
Expand Down
Loading

0 comments on commit 5107301

Please sign in to comment.