diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index 714eace460..fbe989726d 100644 --- a/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -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 = 24 +LIBPATCH = 26 PYDEPS = ["ops>=2.0.0"] @@ -347,16 +347,6 @@ class SecretGroup(Enum): EXTRA = "extra" -# Local map to associate mappings with secrets potentially as a group -SECRET_LABEL_MAP = { - "username": SecretGroup.USER, - "password": SecretGroup.USER, - "uris": SecretGroup.USER, - "tls": SecretGroup.TLS, - "tls-ca": SecretGroup.TLS, -} - - class DataInterfacesError(Exception): """Common ancestor for DataInterfaces related exceptions.""" @@ -453,7 +443,7 @@ def leader_only(f): """Decorator to ensure that only leader can perform given operation.""" def wrapper(self, *args, **kwargs): - if not self.local_unit.is_leader(): + if self.component == self.local_app and not self.local_unit.is_leader(): logger.error( "This operation (%s()) can only be performed by the leader unit", f.__name__ ) @@ -487,12 +477,19 @@ class CachedSecret: The data structure is precisely re-using/simulating as in the actual Secret Storage """ - def __init__(self, charm: CharmBase, label: str, secret_uri: Optional[str] = None): + def __init__( + self, + charm: CharmBase, + component: Union[Application, Unit], + label: str, + secret_uri: Optional[str] = None, + ): self._secret_meta = None self._secret_content = {} self._secret_uri = secret_uri self.label = label self.charm = charm + self.component = component def add_secret(self, content: Dict[str, str], relation: Relation) -> Secret: """Create a new secret.""" @@ -501,8 +498,10 @@ def add_secret(self, content: Dict[str, str], relation: Relation) -> Secret: "Secret is already defined with uri %s", self._secret_uri ) - secret = self.charm.app.add_secret(content, label=self.label) - secret.grant(relation) + secret = self.component.add_secret(content, label=self.label) + if relation.app != self.charm.app: + # If it's not a peer relation, grant is to be applied + secret.grant(relation) self._secret_uri = secret.id self._secret_meta = secret return self._secret_meta @@ -531,8 +530,13 @@ def get_content(self) -> Dict[str, str]: except (ValueError, ModelError) as err: # https://bugs.launchpad.net/juju/+bug/2042596 # Only triggered when 'refresh' is set - msg = "ERROR either URI or label should be used for getting an owned secret but not both" - if isinstance(err, ModelError) and msg not in str(err): + known_model_errors = [ + "ERROR either URI or label should be used for getting an owned secret but not both", + "ERROR secret owner cannot use --refresh", + ] + if isinstance(err, ModelError) and not any( + msg in str(err) for msg in known_model_errors + ): raise # Due to: ValueError: Secret owner cannot use refresh=True self._secret_content = self.meta.get_content() @@ -558,14 +562,15 @@ def get_info(self) -> Optional[SecretInfo]: class SecretCache: """A data structure storing CachedSecret objects.""" - def __init__(self, charm): + def __init__(self, charm: CharmBase, component: Union[Application, Unit]): self.charm = charm + self.component = component self._secrets: Dict[str, CachedSecret] = {} def get(self, label: str, uri: Optional[str] = None) -> Optional[CachedSecret]: """Getting a secret from Juju Secret store or cache.""" if not self._secrets.get(label): - secret = CachedSecret(self.charm, label, uri) + secret = CachedSecret(self.charm, self.component, label, uri) if secret.meta: self._secrets[label] = secret return self._secrets.get(label) @@ -575,7 +580,7 @@ def add(self, label: str, content: Dict[str, str], relation: Relation) -> Cached if self._secrets.get(label): raise SecretAlreadyExistsError(f"Secret {label} already exists") - secret = CachedSecret(self.charm, label) + secret = CachedSecret(self.charm, self.component, label) secret.add_secret(content, relation) self._secrets[label] = secret return self._secrets[label] @@ -587,6 +592,17 @@ def add(self, label: str, content: Dict[str, str], relation: Relation) -> Cached class DataRelation(Object, ABC): """Base relation data mainpulation (abstract) class.""" + SCOPE = Scope.APP + + # Local map to associate mappings with secrets potentially as a group + SECRET_LABEL_MAP = { + "username": SecretGroup.USER, + "password": SecretGroup.USER, + "uris": SecretGroup.USER, + "tls": SecretGroup.TLS, + "tls-ca": SecretGroup.TLS, + } + def __init__(self, charm: CharmBase, relation_name: str) -> None: super().__init__(charm, relation_name) self.charm = charm @@ -598,7 +614,8 @@ def __init__(self, charm: CharmBase, relation_name: str) -> None: self._on_relation_changed_event, ) self._jujuversion = None - self.secrets = SecretCache(self.charm) + self.component = self.local_app if self.SCOPE == Scope.APP else self.local_unit + self.secrets = SecretCache(self.charm, self.component) @property def relations(self) -> List[Relation]: @@ -677,8 +694,7 @@ def _generate_secret_label( """Generate unique group_mappings for secrets within a relation context.""" return f"{relation_name}.{relation_id}.{group_mapping.value}.secret" - @staticmethod - def _generate_secret_field_name(group_mapping: SecretGroup) -> str: + def _generate_secret_field_name(self, group_mapping: SecretGroup) -> str: """Generate unique group_mappings for secrets within a relation context.""" return f"{PROV_SECRET_PREFIX}{group_mapping.value}" @@ -705,8 +721,8 @@ def _relation_from_secret_label(self, secret_label: str) -> Optional[Relation]: except ModelError: return - @staticmethod - def _group_secret_fields(secret_fields: List[str]) -> Dict[SecretGroup, List[str]]: + @classmethod + def _group_secret_fields(cls, secret_fields: List[str]) -> Dict[SecretGroup, List[str]]: """Helper function to arrange secret mappings under their group. NOTE: All unrecognized items end up in the 'extra' secret bucket. @@ -714,7 +730,7 @@ def _group_secret_fields(secret_fields: List[str]) -> Dict[SecretGroup, List[str """ secret_fieldnames_grouped = {} for key in secret_fields: - if group := SECRET_LABEL_MAP.get(key): + if group := cls.SECRET_LABEL_MAP.get(key): secret_fieldnames_grouped.setdefault(group, []).append(key) else: secret_fieldnames_grouped.setdefault(SecretGroup.EXTRA, []).append(key) @@ -736,22 +752,22 @@ def _get_group_secret_contents( return {k: v for k, v in secret_data.items() if k in secret_fields} return {} - @staticmethod + @classmethod def _content_for_secret_group( - content: Dict[str, str], secret_fields: Set[str], group_mapping: SecretGroup + cls, content: Dict[str, str], secret_fields: Set[str], group_mapping: SecretGroup ) -> Dict[str, str]: """Select : pairs from input, that belong to this particular Secret group.""" if group_mapping == SecretGroup.EXTRA: return { k: v for k, v in content.items() - if k in secret_fields and k not in SECRET_LABEL_MAP.keys() + if k in secret_fields and k not in cls.SECRET_LABEL_MAP.keys() } return { k: v for k, v in content.items() - if k in secret_fields and SECRET_LABEL_MAP.get(k) == group_mapping + if k in secret_fields and cls.SECRET_LABEL_MAP.get(k) == group_mapping } @juju_secrets_only @@ -784,7 +800,7 @@ def _process_secret_fields( fallback_to_databag = ( req_secret_fields and self.local_unit.is_leader() - and set(req_secret_fields) & set(relation.data[self.local_app]) + and set(req_secret_fields) & set(relation.data[self.component]) ) normal_fields = set(impacted_rel_fields) @@ -807,7 +823,7 @@ def _process_secret_fields( return (result, normal_fields) def _fetch_relation_data_without_secrets( - self, app: Application, relation: Relation, fields: Optional[List[str]] + self, component: Union[Application, Unit], relation: Relation, fields: Optional[List[str]] ) -> Dict[str, str]: """Fetching databag contents when no secrets are involved. @@ -816,17 +832,19 @@ def _fetch_relation_data_without_secrets( This is used typically when the Provides side wants to read the Requires side's data, or when the Requires side may want to read its own data. """ - if app not in relation.data or not relation.data[app]: + if component not in relation.data or not relation.data[component]: return {} if fields: - return {k: relation.data[app][k] for k in fields if k in relation.data[app]} + return { + k: relation.data[component][k] for k in fields if k in relation.data[component] + } else: - return dict(relation.data[app]) + return dict(relation.data[component]) def _fetch_relation_data_with_secrets( self, - app: Application, + component: Union[Application, Unit], req_secret_fields: Optional[List[str]], relation: Relation, fields: Optional[List[str]] = None, @@ -842,10 +860,10 @@ def _fetch_relation_data_with_secrets( normal_fields = [] if not fields: - if app not in relation.data or not relation.data[app]: + if component not in relation.data or not relation.data[component]: return {} - all_fields = list(relation.data[app].keys()) + all_fields = list(relation.data[component].keys()) normal_fields = [field for field in all_fields if not self._is_secret_field(field)] # There must have been secrets there @@ -862,38 +880,35 @@ def _fetch_relation_data_with_secrets( # (Typically when Juju3 Requires meets Juju2 Provides) if normal_fields: result.update( - self._fetch_relation_data_without_secrets(app, relation, list(normal_fields)) + self._fetch_relation_data_without_secrets(component, relation, list(normal_fields)) ) return result def _update_relation_data_without_secrets( - self, app: Application, relation: Relation, data: Dict[str, str] + self, component: Union[Application, Unit], relation: Relation, data: Dict[str, str] ) -> None: """Updating databag contents when no secrets are involved.""" - if app not in relation.data or relation.data[app] is None: + if component not in relation.data or relation.data[component] is None: return - if any(self._is_secret_field(key) for key in data.keys()): - raise SecretsIllegalUpdateError("Can't update secret {key}.") - if relation: - relation.data[app].update(data) + relation.data[component].update(data) def _delete_relation_data_without_secrets( - self, app: Application, relation: Relation, fields: List[str] + self, component: Union[Application, Unit], relation: Relation, fields: List[str] ) -> None: """Remove databag fields 'fields' from Relation.""" - if app not in relation.data or not relation.data[app]: + if component not in relation.data or relation.data[component] is None: return for field in fields: try: - relation.data[app].pop(field) + relation.data[component].pop(field) except KeyError: - logger.debug( - "Non-existing field was attempted to be removed from the databag %s, %s", - str(relation.id), + logger.error( + "Non-existing field '%s' was attempted to be removed from the databag (relation ID: %s)", str(field), + str(relation.id), ) pass @@ -954,7 +969,6 @@ def fetch_relation_field( .get(field) ) - @leader_only def fetch_my_relation_data( self, relation_ids: Optional[List[int]] = None, @@ -983,7 +997,6 @@ def fetch_my_relation_data( data[relation.id] = self._fetch_my_specific_relation_data(relation, fields) return data - @leader_only def fetch_my_relation_field( self, relation_id: int, field: str, relation_name: Optional[str] = None ) -> Optional[str]: @@ -1035,27 +1048,38 @@ def _diff(self, event: RelationChangedEvent) -> Diff: @juju_secrets_only def _add_relation_secret( - self, relation: Relation, content: Dict[str, str], group_mapping: SecretGroup + self, + relation: Relation, + group_mapping: SecretGroup, + secret_fields: Set[str], + data: Dict[str, str], + uri_to_databag=True, ) -> bool: """Add a new Juju Secret that will be registered in the relation databag.""" secret_field = self._generate_secret_field_name(group_mapping) - if relation.data[self.local_app].get(secret_field): + if uri_to_databag and relation.data[self.component].get(secret_field): logging.error("Secret for relation %s already exists, not adding again", relation.id) return False + content = self._content_for_secret_group(data, secret_fields, group_mapping) + label = self._generate_secret_label(self.relation_name, relation.id, group_mapping) secret = self.secrets.add(label, content, relation) # According to lint we may not have a Secret ID - if secret.meta and secret.meta.id: - relation.data[self.local_app][secret_field] = secret.meta.id + if uri_to_databag and secret.meta and secret.meta.id: + relation.data[self.component][secret_field] = secret.meta.id # Return the content that was added return True @juju_secrets_only def _update_relation_secret( - self, relation: Relation, content: Dict[str, str], group_mapping: SecretGroup + self, + relation: Relation, + group_mapping: SecretGroup, + secret_fields: Set[str], + data: Dict[str, str], ) -> bool: """Update the contents of an existing Juju Secret, referred in the relation databag.""" secret = self._get_relation_secret(relation.id, group_mapping) @@ -1064,6 +1088,8 @@ def _update_relation_secret( logging.error("Can't update secret for relation %s", relation.id) return False + content = self._content_for_secret_group(data, secret_fields, group_mapping) + old_content = secret.get_content() full_content = copy.deepcopy(old_content) full_content.update(content) @@ -1078,13 +1104,13 @@ def _add_or_update_relation_secrets( group: SecretGroup, secret_fields: Set[str], data: Dict[str, str], + uri_to_databag=True, ) -> bool: """Update contents for Secret group. If the Secret doesn't exist, create it.""" - secret_content = self._content_for_secret_group(data, secret_fields, group) if self._get_relation_secret(relation.id, group): - return self._update_relation_secret(relation, secret_content, group) + return self._update_relation_secret(relation, group, secret_fields, data) else: - return self._add_relation_secret(relation, secret_content, group) + return self._add_relation_secret(relation, group, secret_fields, data, uri_to_databag) @juju_secrets_only def _delete_relation_secret( @@ -1116,7 +1142,7 @@ def _delete_relation_secret( if not new_content: field = self._generate_secret_field_name(group) try: - relation.data[self.local_app].pop(field) + relation.data[self.component].pop(field) except KeyError: pass @@ -1233,6 +1259,11 @@ def set_tls_ca(self, relation_id: int, tls_ca: str) -> None: """ self.update_relation_data(relation_id, {"tls-ca": tls_ca}) + # Public functions -- inherited + + fetch_my_relation_data = leader_only(DataRelation.fetch_my_relation_data) + fetch_my_relation_field = leader_only(DataRelation.fetch_my_relation_field) + class DataRequires(DataRelation): """Requires-side of the relation.""" @@ -1297,7 +1328,7 @@ def _register_secret_to_relation( label = self._generate_secret_label(relation_name, relation_id, group) # Fetchin the Secret's meta information ensuring that it's locally getting registered with - CachedSecret(self.charm, label, secret_id).meta + CachedSecret(self.charm, self.component, label, secret_id).meta def _register_secrets_to_relation(self, relation: Relation, params_name_list: List[str]): """Make sure that secrets of the provided list are locally 'registered' from the databag. @@ -1426,6 +1457,219 @@ def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: """ return self._delete_relation_data_without_secrets(self.local_app, relation, fields) + # Public functions -- inherited + + fetch_my_relation_data = leader_only(DataRelation.fetch_my_relation_data) + fetch_my_relation_field = leader_only(DataRelation.fetch_my_relation_field) + + +# Base DataPeer + + +class DataPeer(DataRequires, DataProvides): + """Represents peer relations.""" + + SECRET_FIELDS = ["operator-password"] + SECRET_FIELD_NAME = "internal_secret" + SECRET_LABEL_MAP = {} + + def __init__( + self, + charm, + relation_name: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + secret_field_name: Optional[str] = None, + deleted_label: Optional[str] = None, + ): + """Manager of base client relations.""" + DataRequires.__init__( + self, charm, relation_name, extra_user_roles, additional_secret_fields + ) + self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME + self.deleted_label = deleted_label + + @property + def scope(self) -> Optional[Scope]: + """Turn component information into Scope.""" + if isinstance(self.component, Application): + return Scope.APP + if isinstance(self.component, Unit): + return Scope.UNIT + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + pass + + def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: + """Event emitted when the secret has changed.""" + pass + + def _generate_secret_label( + self, relation_name: str, relation_id: int, group_mapping: SecretGroup + ) -> str: + members = [self.charm.app.name] + if self.scope: + members.append(self.scope.value) + return f"{'.'.join(members)}" + + def _generate_secret_field_name(self, group_mapping: SecretGroup = SecretGroup.EXTRA) -> str: + """Generate unique group_mappings for secrets within a relation context.""" + return f"{self.secret_field_name}" + + @juju_secrets_only + def _get_relation_secret( + self, + relation_id: int, + group_mapping: SecretGroup = SecretGroup.EXTRA, + relation_name: Optional[str] = None, + ) -> Optional[CachedSecret]: + """Retrieve a Juju Secret specifically for peer relations. + + In case this code may be executed within a rolling upgrade, and we may need to + migrate secrets from the databag to labels, we make sure to stick the correct + label on the secret, and clean up the local databag. + """ + if not relation_name: + relation_name = self.relation_name + + relation = self.charm.model.get_relation(relation_name, relation_id) + if not relation: + return + + label = self._generate_secret_label(relation_name, relation_id, group_mapping) + secret_uri = relation.data[self.component].get(self._generate_secret_field_name(), None) + + # Fetching the secret with fallback to URI (in case label is not yet known) + # Label would we "stuck" on the secret in case it is found + secret = self.secrets.get(label, secret_uri) + + # Either app scope secret with leader executing, or unit scope secret + leader_or_unit_scope = self.component != self.local_app or self.local_unit.is_leader() + if secret_uri and secret and leader_or_unit_scope: + # Databag reference to the secret URI can be removed, now that it's labelled + relation.data[self.component].pop(self._generate_secret_field_name(), None) + return secret + + def _get_group_secret_contents( + self, + relation: Relation, + group: SecretGroup, + secret_fields: Optional[Union[Set[str], List[str]]] = None, + ) -> Dict[str, str]: + """Helper function to retrieve collective, requested contents of a secret.""" + result = super()._get_group_secret_contents(relation, group, secret_fields) + if not self.deleted_label: + return result + return {key: result[key] for key in result if result[key] != self.deleted_label} + + def _remove_secret_from_databag(self, relation, fields: List[str]) -> None: + """For Rolling Upgrades -- when moving from databag to secrets usage. + + Practically what happens here is to remove stuff from the databag that is + to be stored in secrets. + """ + if not self.secret_fields: + return + + secret_fields_passed = set(self.secret_fields) & set(fields) + for field in secret_fields_passed: + if self._fetch_relation_data_without_secrets(self.component, relation, [field]): + self._delete_relation_data_without_secrets(self.component, relation, [field]) + + def _fetch_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetch data available (directily or indirectly -- i.e. secrets) from the relation.""" + return self._fetch_relation_data_with_secrets( + self.component, self.secret_fields, relation, fields + ) + + def _fetch_my_specific_relation_data( + self, relation: Relation, fields: Optional[List[str]] + ) -> Dict[str, str]: + """Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + return self._fetch_relation_data_with_secrets( + self.component, self.secret_fields, relation, fields + ) + + def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: + """Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + self._remove_secret_from_databag(relation, list(data.keys())) + _, normal_fields = self._process_secret_fields( + relation, + self.secret_fields, + list(data), + self._add_or_update_relation_secrets, + data=data, + uri_to_databag=False, + ) + + normal_content = {k: v for k, v in data.items() if k in normal_fields} + self._update_relation_data_without_secrets(self.component, relation, normal_content) + + def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: + """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" + if self.secret_fields and self.deleted_label: + current_data = self.fetch_my_relation_data([relation.id], fields) + if current_data is not None: + # Check if the secret we wanna delete actually exists + # Given the "deleted label", here we can't rely on the default mechanism (i.e. 'key not found') + if non_existent := (set(fields) & set(self.secret_fields)) - set( + current_data.get(relation.id, []) + ): + logger.error( + "Non-existing secret %s was attempted to be removed.", non_existent + ) + + _, normal_fields = self._process_secret_fields( + relation, + self.secret_fields, + fields, + self._update_relation_secret, + data={field: self.deleted_label for field in fields}, + ) + else: + _, normal_fields = self._process_secret_fields( + relation, self.secret_fields, fields, self._delete_relation_secret, fields=fields + ) + self._delete_relation_data_without_secrets(self.component, relation, list(normal_fields)) + + def fetch_relation_data( + self, + relation_ids: Optional[List[int]] = None, + fields: Optional[List[str]] = None, + relation_name: Optional[str] = None, + ) -> Dict[int, Dict[str, str]]: + """This method makes no sense for a Peer Relation.""" + raise NotImplementedError( + "Peer Relation only supports 'self-side' fetch methods: " + "fetch_my_relation_data() and fetch_my_relation_field()" + ) + + def fetch_relation_field( + self, relation_id: int, field: str, relation_name: Optional[str] = None + ) -> Optional[str]: + """This method makes no sense for a Peer Relation.""" + raise NotImplementedError( + "Peer Relation only supports 'self-side' fetch methods: " + "fetch_my_relation_data() and fetch_my_relation_field()" + ) + + # Public functions -- inherited + + fetch_my_relation_data = DataRelation.fetch_my_relation_data + fetch_my_relation_field = DataRelation.fetch_my_relation_field + + +class DataPeerUnit(DataPeer): + """Unit databag representation.""" + + SCOPE = Scope.UNIT + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # General events diff --git a/poetry.lock b/poetry.lock index e00c2c0a59..a598d7810c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -970,6 +970,20 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, +] + +[package.extras] +dev = ["jinja2"] + [[package]] name = "paramiko" version = "2.12.0" @@ -2062,4 +2076,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "32d9e58b979992d6e039044917530d8768b3806f67891388ce0ff04b1ac71ffc" +content-hash = "a88232e91574400f8d6b44b61e4b7e1aa119bfe05ed3eac80e4c39209c100970" diff --git a/pyproject.toml b/pyproject.toml index 3c32a00d13..4587e061b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ optional = true coverage = {extras = ["toml"], version = "^7.3.2"} pytest = "^7.4.0" pytest-asyncio = "^0.21.1" +parameterized = "^0.9.0" jsonschema = "^4.19.1" psycopg2-binary = "^2.9.9" jinja2 = "^3.1.2" diff --git a/src/charm.py b/src/charm.py index cd2bc90529..23955fa606 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,8 +10,9 @@ import time from typing import Dict, List, Literal, Optional, Set, get_args +from charms.data_platform_libs.v0.data_interfaces import DataPeer, DataPeerUnit from charms.data_platform_libs.v0.data_models import TypedCharmBase -from charms.data_platform_libs.v0.data_secrets import SecretCache, generate_secret_label +from charms.data_platform_libs.v0.data_secrets import SecretCache from charms.grafana_agent.v0.cos_agent import COSAgentProvider from charms.operator_libs_linux.v2 import snap from charms.postgresql_k8s.v0.postgresql import ( @@ -102,6 +103,31 @@ def __init__(self, *args): super().__init__(*args) self.secrets = SecretCache(self) + self.peer_relation_app = DataPeer( + self, + relation_name=PEER, + additional_secret_fields=[ + "monitoring-password", + "operator-password", + "replication-password", + "rewind-password", + ], + secret_field_name=SECRET_INTERNAL_LABEL, + deleted_label=SECRET_DELETED_LABEL, + ) + self.peer_relation_unit = DataPeerUnit( + self, + relation_name=PEER, + additional_secret_fields=[ + "key", + "csr", + "cauth", + "cert", + "chain", + ], + secret_field_name=SECRET_INTERNAL_LABEL, + deleted_label=SECRET_DELETED_LABEL, + ) juju_version = JujuVersion.from_environ() if juju_version.major > 2: @@ -207,43 +233,24 @@ def _scope_obj(self, scope: Scopes): def _translate_field_to_secret_key(self, key: str) -> str: """Change 'key' to secrets-compatible key field.""" + if not JujuVersion.from_environ().has_secrets: + return key key = SECRET_KEY_OVERRIDES.get(key, key) new_key = key.replace("_", "-") return new_key.strip("-") - def _safe_get_secret(self, scope: Scopes, label: str) -> SecretCache: - """Safety measure, for upgrades between versions based on secret URI usage to others with labels usage. - - If the secret can't be retrieved by label, we search for the uri -- and if found, we "stick" the - label on the secret for further usage. - """ - secret_uri = self._peer_data(scope).get(SECRET_INTERNAL_LABEL, None) - secret = self.secrets.get(label, secret_uri) - - # Since now we switched to labels, the databag reference can be removed - if secret_uri and secret and scope == APP_SCOPE and self.unit.is_leader(): - self._peer_data(scope).pop(SECRET_INTERNAL_LABEL, None) - return secret - def get_secret(self, scope: Scopes, key: str) -> Optional[str]: """Get secret from the secret storage.""" if scope not in get_args(Scopes): raise RuntimeError("Unknown secret scope.") - if value := self._peer_data(scope).get(key, None): - return value - - if JujuVersion.from_environ().has_secrets: - label = generate_secret_label(self, scope) - secret = self._safe_get_secret(scope, label) - - if not secret: - return - - secret_key = self._translate_field_to_secret_key(key) - value = secret.get_content().get(secret_key) - if value != SECRET_DELETED_LABEL: - return value + peers = self.model.get_relation(PEER) + secret_key = self._translate_field_to_secret_key(key) + if scope == APP_SCOPE: + value = self.peer_relation_app.fetch_my_relation_field(peers.id, secret_key) + else: + value = self.peer_relation_unit.fetch_my_relation_field(peers.id, secret_key) + return value def set_secret(self, scope: Scopes, key: str, value: Optional[str]) -> Optional[str]: """Set secret from the secret storage.""" @@ -253,52 +260,24 @@ def set_secret(self, scope: Scopes, key: str, value: Optional[str]) -> Optional[ if not value: return self.remove_secret(scope, key) - if JujuVersion.from_environ().has_secrets: - # Charm must have been upgraded since last run - # We move from databag to secrets - self._peer_data(scope).pop(key, None) - - secret_key = self._translate_field_to_secret_key(key) - label = generate_secret_label(self, scope) - secret = self._safe_get_secret(scope, label) - if not secret: - self.secrets.add(label, {secret_key: value}, scope) - else: - content = secret.get_content() - content.update({secret_key: value}) - secret.set_content(content) - return label + peers = self.model.get_relation(PEER) + secret_key = self._translate_field_to_secret_key(key) + if scope == APP_SCOPE: + self.peer_relation_app.update_relation_data(peers.id, {secret_key: value}) else: - self._peer_data(scope).update({key: value}) + self.peer_relation_unit.update_relation_data(peers.id, {secret_key: value}) def remove_secret(self, scope: Scopes, key: str) -> None: """Removing a secret.""" if scope not in get_args(Scopes): raise RuntimeError("Unknown secret scope.") - if JujuVersion.from_environ().has_secrets: - secret_key = self._translate_field_to_secret_key(key) - label = generate_secret_label(self, scope) - secret = self.secrets.get(label) - - if not secret: - return - - content = secret.get_content() - - if not content.get(secret_key) or content[secret_key] == SECRET_DELETED_LABEL: - logger.error(f"Non-existing secret {scope}:{key} was attempted to be removed.") - return - - content[secret_key] = SECRET_DELETED_LABEL - secret.set_content(content) - # Just in case we started on databag - self._peer_data(scope).pop(key, None) + peers = self.model.get_relation(PEER) + secret_key = self._translate_field_to_secret_key(key) + if scope == APP_SCOPE: + self.peer_relation_app.delete_relation_data(peers.id, [secret_key]) else: - try: - self._peer_data(scope).pop(key) - except KeyError: - logger.error(f"Non-existing secret {scope}:{key} was attempted to be removed.") + self.peer_relation_unit.delete_relation_data(peers.id, [secret_key]) @property def is_cluster_initialised(self) -> bool: diff --git a/tests/integration/test_db_admin.py b/tests/integration/test_db_admin.py index 4c7868035b..eb0d2291db 100644 --- a/tests/integration/test_db_admin.py +++ b/tests/integration/test_db_admin.py @@ -34,6 +34,7 @@ @pytest.mark.group(1) +@pytest.mark.skip(reason="DB Admin tests are currently broken") async def test_landscape_scalable_bundle_db(ops_test: OpsTest, charm: str) -> None: """Deploy Landscape Scalable Bundle to test the 'db-admin' relation.""" await ops_test.model.deploy( diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index d579020fbf..53998a0727 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,9 +1,11 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. +import logging import subprocess import unittest -from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch, sentinel +from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch +import pytest from charms.operator_libs_linux.v2 import snap from charms.postgresql_k8s.v0.postgresql import ( PostgreSQLCreateUserError, @@ -11,23 +13,20 @@ PostgreSQLUpdateUserPasswordError, ) from ops.framework import EventBase -from ops.model import ActiveStatus, BlockedStatus, WaitingStatus +from ops.model import ActiveStatus, BlockedStatus, RelationDataTypeError, WaitingStatus from ops.testing import Harness +from parameterized import parameterized from tenacity import RetryError from charm import NO_PRIMARY_MESSAGE, PostgresqlOperatorCharm from cluster import RemoveRaftMemberFailedError -from constants import ( - PEER, - POSTGRESQL_SNAP_NAME, - SECRET_DELETED_LABEL, - SNAP_PACKAGES, -) +from constants import PEER, POSTGRESQL_SNAP_NAME, SECRET_INTERNAL_LABEL, SNAP_PACKAGES from tests.helpers import patch_network_get CREATE_CLUSTER_CONF_PATH = "/etc/postgresql-common/createcluster.d/pgcharm.conf" +# @pytest.mark.usefixtures("juju_has_secrets") class TestCharm(unittest.TestCase): def setUp(self): self._peer_relation = PEER @@ -40,6 +39,10 @@ def setUp(self): self.rel_id = self.harness.add_relation(self._peer_relation, self.charm.app.name) self.harness.add_relation("upgrade", self.charm.app.name) + @pytest.fixture + def use_caplog(self, caplog): + self._caplog = caplog + @patch_network_get(private_address="1.1.1.1") @patch("charm.subprocess.check_call") @patch("charm.snap.SnapCache") @@ -609,8 +612,11 @@ def test_on_start_after_blocked_state( # Assert the status didn't change. self.assertEqual(self.harness.model.unit.status, initial_status) - def test_on_get_password(self): + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm.update_config") + def test_on_get_password(self, _): # Create a mock event and set passwords in peer relation data. + self.harness.set_leader(True) mock_event = MagicMock(params={}) self.harness.update_relation_data( self.rel_id, @@ -958,137 +964,6 @@ def test_install_snap_packages(self, _snap_cache): _snap_package.ensure.assert_not_called() _snap_package.hold.assert_not_called() - def test_scope_obj(self): - assert self.charm._scope_obj("app") == self.charm.framework.model.app - assert self.charm._scope_obj("unit") == self.charm.framework.model.unit - assert self.charm._scope_obj("test") is None - - @patch_network_get(private_address="1.1.1.1") - @patch("charm.PostgresqlOperatorCharm._on_leader_elected") - def test_get_secret(self, _): - self.harness.set_leader() - - # Test application scope. - assert self.charm.get_secret("app", "password") is None - self.harness.update_relation_data( - self.rel_id, self.charm.app.name, {"password": "test-password"} - ) - assert self.charm.get_secret("app", "password") == "test-password" - - # Test unit scope. - assert self.charm.get_secret("unit", "password") is None - self.harness.update_relation_data( - self.rel_id, self.charm.unit.name, {"password": "test-password"} - ) - assert self.charm.get_secret("unit", "password") == "test-password" - - @patch_network_get(private_address="1.1.1.1") - @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) - @patch("charm.PostgresqlOperatorCharm._on_leader_elected") - def test_get_secret_juju(self, _, __): - self.harness.set_leader() - with patch.object(self.charm, "secrets") as _secret_cache: - # Test application scope. - _secret_cache.get.return_value = None - assert self.charm.get_secret("app", "password") is None - _secret_cache.get.assert_called_once_with("postgresql.app", None) - _secret_cache.reset_mock() - - _secret_cache.get.return_value = Mock() - _secret_cache.get.return_value.get_content.return_value.get.return_value = ( - sentinel.test_password - ) - assert self.charm.get_secret("app", "password") == sentinel.test_password - _secret_cache.get.assert_called_once_with("postgresql.app", None) - _secret_cache.get.return_value.get_content.return_value.get.assert_called_once_with( - "password" - ) - _secret_cache.reset_mock() - - # Test unit scope. - _secret_cache.get.return_value = None - assert self.charm.get_secret("unit", "password") is None - _secret_cache.get.assert_called_once_with("postgresql.unit", None) - _secret_cache.reset_mock() - - _secret_cache.get.return_value = Mock() - _secret_cache.get.return_value.get_content.return_value.get.return_value = ( - sentinel.test_password - ) - assert self.charm.get_secret("unit", "password") == sentinel.test_password - _secret_cache.get.assert_called_once_with("postgresql.unit", None) - _secret_cache.get.return_value.get_content.return_value.get.assert_called_once_with( - "password" - ) - _secret_cache.reset_mock() - - with self.assertRaises(RuntimeError): - self.charm.get_secret("test", "password") - - @patch_network_get(private_address="1.1.1.1") - @patch("charm.PostgresqlOperatorCharm._on_leader_elected") - def test_set_secret(self, _): - self.harness.set_leader() - - # Test application scope. - assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) - self.charm.set_secret("app", "password", "test-password") - assert ( - self.harness.get_relation_data(self.rel_id, self.charm.app.name)["password"] - == "test-password" - ) - self.charm.set_secret("app", "password", None) - assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) - - # Test unit scope. - assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) - self.charm.set_secret("unit", "password", "test-password") - assert ( - self.harness.get_relation_data(self.rel_id, self.charm.unit.name)["password"] - == "test-password" - ) - self.charm.set_secret("unit", "password", None) - assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) - - with self.assertRaises(RuntimeError): - self.charm.set_secret("test", "password", "test") - - @patch_network_get(private_address="1.1.1.1") - @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) - @patch("charm.PostgresqlOperatorCharm._on_leader_elected") - def test_set_secret_juju(self, _, __): - self.harness.set_leader() - with patch.object(self.charm, "secrets") as _secret_cache: - # Test application scope. - self.charm.set_secret("app", "password", "test-password") - _secret_cache.get.assert_called_once_with("postgresql.app", None) - _secret_cache.get().get_content().update.assert_called_once_with( - {"password": "test-password"} - ) - _secret_cache.reset_mock() - - self.charm.set_secret("app", "password", None) - _secret_cache.get.assert_called_once_with("postgresql.app") - content = _secret_cache.get().get_content() - content.__setitem__.assert_called_once_with("password", SECRET_DELETED_LABEL) - _secret_cache.get().set_content.assert_called_once_with(content) - _secret_cache.reset_mock() - - # Test unit scope. - self.charm.set_secret("unit", "password", "test-password") - _secret_cache.get.assert_called_once_with("postgresql.unit", None) - _secret_cache.get().get_content().update.assert_called_once_with( - {"password": "test-password"} - ) - _secret_cache.reset_mock() - - self.charm.set_secret("unit", "password", None) - _secret_cache.get.assert_called_once_with("postgresql.unit") - content = _secret_cache.get().get_content() - content.__setitem__.assert_called_once_with("password", SECRET_DELETED_LABEL) - _secret_cache.get().set_content.assert_called_once_with(content) - _secret_cache.reset_mock() - @patch( "subprocess.check_call", side_effect=[None, subprocess.CalledProcessError(1, "fake command")], @@ -1431,7 +1306,8 @@ def test_reconfigure_cluster( _add_members.assert_called_once_with(mock_event) @patch("charms.postgresql_k8s.v0.postgresql_tls.PostgreSQLTLS._request_certificate") - def test_update_certificate(self, _request_certificate): + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=False) + def test_update_certificate(self, _, _request_certificate): # If there is no current TLS files, _request_certificate should be called # only when the certificates relation is established. self.charm._update_certificate() @@ -1455,6 +1331,36 @@ def test_update_certificate(self, _request_certificate): self.charm._update_certificate() _request_certificate.assert_called_once_with(private_key) + self.harness.charm.get_secret("unit", "ca") == ca + self.harness.charm.get_secret("unit", "cert") == cert + self.harness.charm.get_secret("unit", "key") == key + self.harness.charm.get_secret("unit", "private-key") == private_key + + @patch("charms.postgresql_k8s.v0.postgresql_tls.PostgreSQLTLS._request_certificate") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_update_certificate_secrets(self, _, _request_certificate): + # If there is no current TLS files, _request_certificate should be called + # only when the certificates relation is established. + self.charm._update_certificate() + _request_certificate.assert_not_called() + + # Test with already present TLS files (when they will be replaced by new ones). + ca = "fake CA" + cert = "fake certificate" + key = private_key = "fake private key" + self.harness.charm.set_secret("unit", "ca", ca) + self.harness.charm.set_secret("unit", "cert", cert) + self.harness.charm.set_secret("unit", "key", key) + self.harness.charm.set_secret("unit", "private-key", private_key) + + self.charm._update_certificate() + _request_certificate.assert_called_once_with(private_key) + + self.harness.charm.get_secret("unit", "ca") == ca + self.harness.charm.get_secret("unit", "cert") == cert + self.harness.charm.get_secret("unit", "key") == key + self.harness.charm.get_secret("unit", "private-key") == private_key + @patch_network_get(private_address="1.1.1.1") @patch("charm.PostgresqlOperatorCharm._update_certificate") @patch("charm.Patroni.stop_patroni") @@ -1570,3 +1476,265 @@ def test_client_relations(self): self.assertEqual( self.charm.client_relations, [database_relation, db_relation, db_admin_relation] ) + + # + # Secrets + # + + def test_scope_obj(self): + assert self.charm._scope_obj("app") == self.charm.framework.model.app + assert self.charm._scope_obj("unit") == self.charm.framework.model.unit + assert self.charm._scope_obj("test") is None + + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_get_secret(self, _): + # App level changes require leader privileges + self.harness.set_leader() + # Test application scope. + assert self.charm.get_secret("app", "password") is None + self.harness.update_relation_data( + self.rel_id, self.charm.app.name, {"password": "test-password"} + ) + assert self.charm.get_secret("app", "password") == "test-password" + + # Unit level changes don't require leader privileges + self.harness.set_leader(False) + # Test unit scope. + assert self.charm.get_secret("unit", "password") is None + self.harness.update_relation_data( + self.rel_id, self.charm.unit.name, {"password": "test-password"} + ) + assert self.charm.get_secret("unit", "password") == "test-password" + + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_on_get_password_secrets(self, mock1, mock2): + # Create a mock event and set passwords in peer relation data. + self.harness.set_leader() + mock_event = MagicMock(params={}) + self.harness.charm.set_secret("app", "operator-password", "test-password") + self.harness.charm.set_secret("app", "replication-password", "replication-test-password") + + # Test providing an invalid username. + mock_event.params["username"] = "user" + self.charm._on_get_password(mock_event) + mock_event.fail.assert_called_once() + mock_event.set_results.assert_not_called() + + # Test without providing the username option. + mock_event.reset_mock() + del mock_event.params["username"] + self.charm._on_get_password(mock_event) + mock_event.set_results.assert_called_once_with({"password": "test-password"}) + + # Also test providing the username option. + mock_event.reset_mock() + mock_event.params["username"] = "replication" + self.charm._on_get_password(mock_event) + mock_event.set_results.assert_called_once_with({"password": "replication-test-password"}) + + @parameterized.expand([("app"), ("unit")]) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_get_secret_secrets(self, scope, _, __): + self.harness.set_leader() + + assert self.charm.get_secret(scope, "operator-password") is None + self.charm.set_secret(scope, "operator-password", "test-password") + assert self.charm.get_secret(scope, "operator-password") == "test-password" + + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_set_secret(self, _): + self.harness.set_leader() + + # Test application scope. + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) + self.charm.set_secret("app", "password", "test-password") + assert ( + self.harness.get_relation_data(self.rel_id, self.charm.app.name)["password"] + == "test-password" + ) + self.charm.set_secret("app", "password", None) + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) + + # Test unit scope. + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) + self.charm.set_secret("unit", "password", "test-password") + assert ( + self.harness.get_relation_data(self.rel_id, self.charm.unit.name)["password"] + == "test-password" + ) + self.charm.set_secret("unit", "password", None) + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) + + with self.assertRaises(RuntimeError): + self.charm.set_secret("test", "password", "test") + + @parameterized.expand([("app", True), ("unit", True), ("unit", False)]) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_set_reset_new_secret(self, scope, is_leader, _, __): + """NOTE: currently ops.testing seems to allow for non-leader to set secrets too!""" + # App has to be leader, unit can be either + self.harness.set_leader(is_leader) + # Getting current password + self.harness.charm.set_secret(scope, "new-secret", "bla") + assert self.harness.charm.get_secret(scope, "new-secret") == "bla" + + # Reset new secret + self.harness.charm.set_secret(scope, "new-secret", "blablabla") + assert self.harness.charm.get_secret(scope, "new-secret") == "blablabla" + + # Set another new secret + self.harness.charm.set_secret(scope, "new-secret2", "blablabla") + assert self.harness.charm.get_secret(scope, "new-secret2") == "blablabla" + + @parameterized.expand([("app", True), ("unit", True), ("unit", False)]) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_invalid_secret(self, scope, is_leader, _, __): + # App has to be leader, unit can be either + self.harness.set_leader(is_leader) + + with self.assertRaises(RelationDataTypeError): + self.harness.charm.set_secret(scope, "somekey", 1) + + self.harness.charm.set_secret(scope, "somekey", "") + assert self.harness.charm.get_secret(scope, "somekey") is None + + @pytest.mark.usefixtures("use_caplog") + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_delete_password(self, _): + """NOTE: currently ops.testing seems to allow for non-leader to remove secrets too!""" + self.harness.set_leader(True) + self.harness.update_relation_data( + self.rel_id, self.charm.app.name, {"replication": "somepw"} + ) + self.harness.charm.remove_secret("app", "replication") + assert self.harness.charm.get_secret("app", "replication") is None + + self.harness.set_leader(False) + self.harness.update_relation_data( + self.rel_id, self.charm.unit.name, {"somekey": "somevalue"} + ) + self.harness.charm.remove_secret("unit", "somekey") + assert self.harness.charm.get_secret("unit", "somekey") is None + + self.harness.set_leader(True) + with self._caplog.at_level(logging.ERROR): + self.harness.charm.remove_secret("app", "replication") + assert ( + "Non-existing field 'replication' was attempted to be removed" in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "somekey") + assert "Non-existing field 'somekey' was attempted to be removed" in self._caplog.text + + self.harness.charm.remove_secret("app", "non-existing-secret") + assert ( + "Non-existing field 'non-existing-secret' was attempted to be removed" + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "non-existing-secret") + assert ( + "Non-existing field 'non-existing-secret' was attempted to be removed" + in self._caplog.text + ) + + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @pytest.mark.usefixtures("use_caplog") + def test_delete_existing_password_secrets(self, _, __): + """NOTE: currently ops.testing seems to allow for non-leader to remove secrets too!""" + self.harness.set_leader(True) + self.harness.charm.set_secret("app", "operator-password", "somepw") + self.harness.charm.remove_secret("app", "operator-password") + assert self.harness.charm.get_secret("app", "operator-password") is None + + self.harness.set_leader(False) + self.harness.charm.set_secret("unit", "operator-password", "somesecret") + self.harness.charm.remove_secret("unit", "operator-password") + assert self.harness.charm.get_secret("unit", "operator-password") is None + + self.harness.set_leader(True) + with self._caplog.at_level(logging.ERROR): + self.harness.charm.remove_secret("app", "operator-password") + assert ( + "Non-existing secret {'operator-password'} was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "operator-password") + assert ( + "Non-existing secret {'operator-password'} was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("app", "non-existing-secret") + assert ( + "Non-existing field 'non-existing-secret' was attempted to be removed" + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "non-existing-secret") + assert ( + "Non-existing field 'non-existing-secret' was attempted to be removed" + in self._caplog.text + ) + + @parameterized.expand([("app", True), ("unit", True), ("unit", False)]) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_migration_from_databag(self, scope, is_leader, _, __): + """Check if we're moving on to use secrets when live upgrade from databag to Secrets usage.""" + # App has to be leader, unit can be either + self.harness.set_leader(is_leader) + + # Getting current password + entity = getattr(self.charm, scope) + self.harness.update_relation_data(self.rel_id, entity.name, {"operator-password": "bla"}) + assert self.harness.charm.get_secret(scope, "operator-password") == "bla" + + # Reset new secret + self.harness.charm.set_secret(scope, "operator-password", "blablabla") + assert self.harness.charm.model.get_secret(label=f"postgresql.{scope}") + assert self.harness.charm.get_secret(scope, "operator-password") == "blablabla" + assert "operator-password" not in self.harness.get_relation_data( + self.rel_id, getattr(self.charm, scope).name + ) + + @parameterized.expand([("app", True), ("unit", True), ("unit", False)]) + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + def test_migration_from_single_secret(self, scope, is_leader, _, __): + """Check if we're moving on to use secrets when live upgrade from databag to Secrets usage.""" + # App has to be leader, unit can be either + self.harness.set_leader(is_leader) + + secret = self.harness.charm.app.add_secret({"operator-password": "bla"}) + + # Getting current password + entity = getattr(self.charm, scope) + self.harness.update_relation_data( + self.rel_id, entity.name, {SECRET_INTERNAL_LABEL: secret.id} + ) + assert self.harness.charm.get_secret(scope, "operator-password") == "bla" + + # Reset new secret + self.harness.charm.set_secret(scope, "operator-password", "blablabla") + assert self.harness.charm.model.get_secret(label=f"postgresql.{scope}") + assert self.harness.charm.get_secret(scope, "operator-password") == "blablabla" + assert SECRET_INTERNAL_LABEL not in self.harness.get_relation_data( + self.rel_id, getattr(self.charm, scope).name + )