diff --git a/lib/charms/data_platform_libs/v0/data_secrets.py b/lib/charms/data_platform_libs/v0/data_secrets.py new file mode 100644 index 0000000000..254b9af3df --- /dev/null +++ b/lib/charms/data_platform_libs/v0/data_secrets.py @@ -0,0 +1,143 @@ +"""Secrets related helper classes/functions.""" +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +from typing import Dict, Literal, Optional + +from ops import Secret, SecretInfo +from ops.charm import CharmBase +from ops.model import SecretNotFoundError + +# The unique Charmhub library identifier, never change it +LIBID = "d77fb3d01aba41ed88e837d0beab6be5" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + + +APP_SCOPE = "app" +UNIT_SCOPE = "unit" +Scopes = Literal["app", "unit"] + + +class DataSecretsError(Exception): + """A secret that we want to create already exists.""" + + +class SecretAlreadyExistsError(DataSecretsError): + """A secret that we want to create already exists.""" + + +def generate_secret_label(charm: CharmBase, scope: Scopes) -> str: + """Generate unique group_mappings for secrets within a relation context. + + Defined as a standalone function, as the choice on secret labels definition belongs to the + Application Logic. To be kept separate from classes below, which are simply to provide a + (smart) abstraction layer above Juju Secrets. + """ + members = [charm.app.name, scope] + return f"{'.'.join(members)}" + + +# Secret cache + + +class CachedSecret: + """Abstraction layer above direct Juju access with caching. + + The data structure is precisely re-using/simulating Juju Secrets behavior, while + also making sure not to fetch a secret multiple times within the same event scope. + """ + + def __init__(self, charm: CharmBase, 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 + + def add_secret(self, content: Dict[str, str], scope: Scopes) -> Secret: + """Create a new secret.""" + if self._secret_uri: + raise SecretAlreadyExistsError( + "Secret is already defined with uri %s", self._secret_uri + ) + + if scope == APP_SCOPE: + secret = self.charm.app.add_secret(content, label=self.label) + else: + secret = self.charm.unit.add_secret(content, label=self.label) + self._secret_uri = secret.id + self._secret_meta = secret + return self._secret_meta + + @property + def meta(self) -> Optional[Secret]: + """Getting cached secret meta-information.""" + if self._secret_meta: + return self._secret_meta + + if not (self._secret_uri or self.label): + return + + try: + self._secret_meta = self.charm.model.get_secret(label=self.label) + except SecretNotFoundError: + if self._secret_uri: + self._secret_meta = self.charm.model.get_secret( + id=self._secret_uri, label=self.label + ) + return self._secret_meta + + def get_content(self) -> Dict[str, str]: + """Getting cached secret content.""" + if not self._secret_content: + if self.meta: + self._secret_content = self.meta.get_content() + return self._secret_content + + def set_content(self, content: Dict[str, str]) -> None: + """Setting cached secret content.""" + if self.meta: + self.meta.set_content(content) + self._secret_content = content + + def get_info(self) -> Optional[SecretInfo]: + """Wrapper function for get the corresponding call on the Secret object if any.""" + if self.meta: + return self.meta.get_info() + + +class SecretCache: + """A data structure storing CachedSecret objects.""" + + def __init__(self, charm): + self.charm = charm + 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) + + # Checking if the secret exists, otherwise we don't register it in the cache + if secret.meta: + self._secrets[label] = secret + return self._secrets.get(label) + + def add(self, label: str, content: Dict[str, str], scope: Scopes) -> CachedSecret: + """Adding a secret to Juju Secret.""" + if self._secrets.get(label): + raise SecretAlreadyExistsError(f"Secret {label} already exists") + + secret = CachedSecret(self.charm, label) + secret.add_secret(content, scope) + self._secrets[label] = secret + return self._secrets[label] + + +# END: Secret cache diff --git a/poetry.lock b/poetry.lock index 8254197ffc..cbfa62f791 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "anyio" @@ -949,6 +949,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1048,6 +1058,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" @@ -1219,6 +1243,8 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -1577,6 +1603,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1584,8 +1611,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1602,6 +1636,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1609,6 +1644,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2079,4 +2115,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a92bc7ee5adca12673a90b59e900ee33d2300d78c3e7efcee56c3fc867735822" +content-hash = "ccf63eaf7c62685afd915f1b284f92a450f00a02f216172bf40246b3926f9585" diff --git a/pyproject.toml b/pyproject.toml index cb6df45c62..a6d9179dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ pytest = "^7.4.3" pytest-mock = "^3.12.0" pytest-asyncio = "^0.21.1" psycopg2 = {version = "^2.9.9", extras = ["binary"]} +parameterized = "^0.9.0" [tool.poetry.group.integration] optional = true @@ -77,6 +78,8 @@ juju = "^3.2.2" psycopg2 = {version = "^2.9.9", extras = ["binary"]} boto3 = "^1.28.70" tenacity = "^8.2.3" +ops = "^2.8.0" +pytest-mock = "^3.12.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/src/charm.py b/src/charm.py index aaec92aab9..43fd2bdde4 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,9 +7,10 @@ import json import logging import time -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Literal, Optional, Tuple, get_args 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.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch @@ -25,7 +26,6 @@ from lightkube.models.core_v1 import ServicePort, ServiceSpec from lightkube.models.meta_v1 import ObjectMeta from lightkube.resources.core_v1 import Endpoints, Node, Pod, Service -from ops import JujuVersion from ops.charm import ( ActionEvent, HookEvent, @@ -38,9 +38,9 @@ ActiveStatus, BlockedStatus, Container, + JujuVersion, MaintenanceStatus, Relation, - SecretNotFoundError, Unit, WaitingStatus, ) @@ -61,11 +61,9 @@ REPLICATION_PASSWORD_KEY, REPLICATION_USER, REWIND_PASSWORD_KEY, - SECRET_CACHE_LABEL, SECRET_DELETED_LABEL, SECRET_INTERNAL_LABEL, SECRET_KEY_OVERRIDES, - SECRET_LABEL, SYSTEM_USERS, TLS_CA_FILE, TLS_CERT_FILE, @@ -88,6 +86,8 @@ logging.getLogger("httpcore").setLevel(logging.ERROR) logging.getLogger("httpx").setLevel(logging.ERROR) +Scopes = Literal[APP_SCOPE, UNIT_SCOPE] + class PostgresqlOperatorCharm(TypedCharmBase[CharmConfig]): """Charmed Operator for the PostgreSQL database.""" @@ -97,7 +97,7 @@ class PostgresqlOperatorCharm(TypedCharmBase[CharmConfig]): def __init__(self, *args): super().__init__(*args) - self.secrets = {APP_SCOPE: {}, UNIT_SCOPE: {}} + self.secrets = SecretCache(self) self._postgresql_service = "postgresql" self.pgbackrest_server_service = "pgbackrest server" @@ -191,185 +191,116 @@ def unit_peer_data(self) -> Dict: return relation.data[self.unit] - def _scope_obj(self, scope: str): - if scope == APP_SCOPE: - return self.framework.model.app - if scope == UNIT_SCOPE: - return self.framework.model.unit - - def _juju_secrets_get(self, scope: str) -> Optional[bool]: - """Helper function to get Juju secret.""" - if scope == UNIT_SCOPE: - peer_data = self.unit_peer_data - else: - peer_data = self.app_peer_data - - if not peer_data.get(SECRET_INTERNAL_LABEL): - return - - if SECRET_CACHE_LABEL not in self.secrets[scope]: - for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True): - with attempt: - try: - # NOTE: Secret contents are not yet available! - secret = self.model.get_secret(id=peer_data[SECRET_INTERNAL_LABEL]) - except SecretNotFoundError as e: - logging.debug( - f"No secret found for ID {peer_data[SECRET_INTERNAL_LABEL]}, {e}" - ) - return - - logging.debug(f"Secret {peer_data[SECRET_INTERNAL_LABEL]} downloaded") + def _peer_data(self, scope: Scopes) -> Dict: + """Return corresponding databag for app/unit.""" + relation = self.model.get_relation(PEER) + if relation is None: + return {} - # We keep the secret object around -- needed when applying modifications - self.secrets[scope][SECRET_LABEL] = secret + return relation.data[self._scope_obj(scope)] - # We retrieve and cache actual secret data for the lifetime of the event scope - self.secrets[scope][SECRET_CACHE_LABEL] = secret.get_content() + def _scope_obj(self, scope: Scopes): + if scope == APP_SCOPE: + return self.app + if scope == UNIT_SCOPE: + return self.unit - return bool(self.secrets[scope].get(SECRET_CACHE_LABEL)) + def _translate_field_to_secret_key(self, key: str) -> str: + """Change 'key' to secrets-compatible key field.""" + key = SECRET_KEY_OVERRIDES.get(key, key) + new_key = key.replace("_", "-") + return new_key.strip("-") - def _juju_secret_get_key(self, scope: str, key: str) -> Optional[str]: - if not key: - return + 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. - key = SECRET_KEY_OVERRIDES.get(key, key) + 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) - if self._juju_secrets_get(scope): - secret_cache = self.secrets[scope].get(SECRET_CACHE_LABEL) - if secret_cache: - secret_data = secret_cache.get(key) - if secret_data and secret_data != SECRET_DELETED_LABEL: - logging.debug(f"Getting secret {scope}:{key}") - return secret_data - logging.debug(f"No value found for secret {scope}:{key}") + # 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: str, key: str) -> Optional[str]: + def get_secret(self, scope: Scopes, key: str) -> Optional[str]: """Get secret from the secret storage.""" - if scope not in [APP_SCOPE, UNIT_SCOPE]: + if scope not in get_args(Scopes): raise RuntimeError("Unknown secret scope.") - if scope == UNIT_SCOPE: - result = self.unit_peer_data.get(key, None) - else: - result = self.app_peer_data.get(key, None) - - # TODO change upgrade to switch to secrets once minor version upgrades is done - if result: - return result - - juju_version = JujuVersion.from_environ() - if juju_version.has_secrets: - return self._juju_secret_get_key(scope, key) - - def _juju_secret_set(self, scope: str, key: str, value: str) -> Optional[str]: - """Helper function setting Juju secret.""" - if scope == UNIT_SCOPE: - peer_data = self.unit_peer_data - else: - peer_data = self.app_peer_data - self._juju_secrets_get(scope) - - key = SECRET_KEY_OVERRIDES.get(key, key) - - secret = self.secrets[scope].get(SECRET_LABEL) + if value := self._peer_data(scope).get(key, None): + return value - # It's not the first secret for the scope, we can reuse the existing one - # that was fetched in the previous call - if secret: - secret_cache = self.secrets[scope][SECRET_CACHE_LABEL] - - if secret_cache.get(key) == value: - logging.debug(f"Key {scope}:{key} has this value defined already") - else: - secret_cache[key] = value - try: - secret.set_content(secret_cache) - except OSError as error: - logging.error( - f"Error in attempt to set {scope}:{key}. " - f"Existing keys were: {list(secret_cache.keys())}. {error}" - ) - return - logging.debug(f"Secret {scope}:{key} was {key} set") - - # We need to create a brand-new secret for this scope - else: - scope_obj = self._scope_obj(scope) + if JujuVersion.from_environ().has_secrets: + label = generate_secret_label(self, scope) + for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True): + with attempt: + secret = self._safe_get_secret(scope, label) - secret = scope_obj.add_secret({key: value}) if not secret: - raise RuntimeError(f"Couldn't set secret {scope}:{key}") - - self.secrets[scope][SECRET_LABEL] = secret - self.secrets[scope][SECRET_CACHE_LABEL] = {key: value} - logging.debug(f"Secret {scope}:{key} published (as first). ID: {secret.id}") - peer_data.update({SECRET_INTERNAL_LABEL: secret.id}) - - # TODO change upgrade to switch to secrets once minor version upgrades is done - if key in peer_data: - del peer_data[key] + return - return self.secrets[scope][SECRET_LABEL].id + secret_key = self._translate_field_to_secret_key(key) + value = secret.get_content().get(secret_key) + if value != SECRET_DELETED_LABEL: + return value - def set_secret(self, scope: str, key: str, value: Optional[str]) -> Optional[str]: + def set_secret(self, scope: Scopes, key: str, value: Optional[str]) -> Optional[str]: """Set secret from the secret storage.""" - if scope not in [APP_SCOPE, UNIT_SCOPE]: + if scope not in get_args(Scopes): raise RuntimeError("Unknown secret scope.") if not value: return self.remove_secret(scope, key) - juju_version = JujuVersion.from_environ() + 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) - if juju_version.has_secrets: - self._juju_secret_set(scope, key, value) - return - if scope == UNIT_SCOPE: - self.unit_peer_data.update({key: value}) + 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 else: - self.app_peer_data.update({key: value}) - - def _juju_secret_remove(self, scope: str, key: str) -> None: - """Remove a Juju 3.x secret.""" - self._juju_secrets_get(scope) + self._peer_data(scope).update({key: value}) - key = SECRET_KEY_OVERRIDES.get(key, key) + def remove_secret(self, scope: Scopes, key: str) -> None: + """Removing a secret.""" + if scope not in get_args(Scopes): + raise RuntimeError("Unknown secret scope.") - secret = self.secrets[scope].get(SECRET_LABEL) - if not secret: - logging.error(f"Secret {scope}:{key} wasn't deleted: no secrets are available") - return + if JujuVersion.from_environ().has_secrets: + secret_key = self._translate_field_to_secret_key(key) + label = generate_secret_label(self, scope) + secret = self._safe_get_secret(scope, label) - secret_cache = self.secrets[scope].get(SECRET_CACHE_LABEL) - if not secret_cache or key not in secret_cache: - logging.error(f"No secret {scope}:{key}") - return + if not secret: + return - secret_cache[key] = SECRET_DELETED_LABEL - secret.set_content(secret_cache) - logging.debug(f"Secret {scope}:{key}") + content = secret.get_content() - # TODO change upgrade to switch to secrets once minor version upgrades is done - if scope == UNIT_SCOPE: - peer_data = self.unit_peer_data - else: - peer_data = self.app_peer_data - if key in peer_data: - del peer_data[key] - - def remove_secret(self, scope: str, key: str) -> None: - """Removing a secret.""" - if scope not in [APP_SCOPE, UNIT_SCOPE]: - raise RuntimeError("Unknown secret scope.") + 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 - juju_version = JujuVersion.from_environ() - if juju_version.has_secrets: - return self._juju_secret_remove(scope, key) - if scope == UNIT_SCOPE: - del self.unit_peer_data[key] + content[secret_key] = SECRET_DELETED_LABEL + secret.set_content(content) + # Just in case we started on databag + self.unit_peer_data.pop(key, None) else: - del self.app_peer_data[key] + try: + self._peer_data(scope).pop(key) + except KeyError: + logger.error(f"Non-existing secret {scope}:{key} was attempted to be removed.") @property def is_cluster_initialised(self) -> bool: diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index e0b1ced0e0..4b535d0e2d 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -413,18 +413,28 @@ async def get_leader_unit(ops_test: OpsTest, app: str) -> Optional[Unit]: return leader_unit +async def get_password_on_unit( + ops_test: OpsTest, username: str, unit: Unit, database_app_name: str = DATABASE_APP_NAME +) -> str: + action = await unit.run_action("get-password", **{"username": username}) + result = await action.wait() + return result.results["password"] + + async def get_password( ops_test: OpsTest, username: str = "operator", database_app_name: str = DATABASE_APP_NAME, down_unit: str = None, + unit_name: str = None, ): """Retrieve a user password using the action.""" for unit in ops_test.model.applications[database_app_name].units: - if unit.name != down_unit: - action = await unit.run_action("get-password", **{"username": username}) - result = await action.wait() - return result.results["password"] + if unit.name == down_unit: + continue + + if pw := await get_password_on_unit(ops_test, username, unit, database_app_name): + return pw @retry( diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py index 8b3ad2a3c0..867d608e82 100644 --- a/tests/integration/test_password_rotation.py +++ b/tests/integration/test_password_rotation.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. +import json import time import pytest @@ -10,6 +11,7 @@ from tests.integration.helpers import ( CHARM_SERIES, check_patroni, + get_leader_unit, get_password, restart_patroni, set_password, @@ -45,11 +47,8 @@ async def test_password_rotation(ops_test: OpsTest): replication_password = await get_password(ops_test, "replication") # Get the leader unit name (because passwords can only be set through it). - leader = None - for unit in ops_test.model.applications[APP_NAME].units: - if await unit.is_leader_from_status(): - leader = unit.name - break + leader_unit = await get_leader_unit(ops_test, APP_NAME) + leader = leader_unit.name # Change both passwords. result = await set_password(ops_test, unit_name=leader) @@ -77,3 +76,51 @@ async def test_password_rotation(ops_test: OpsTest): if not await unit.is_leader_from_status(): await restart_patroni(ops_test, unit.name) assert await check_patroni(ops_test, unit.name, restart_time) + + +@pytest.mark.juju3 +async def test_password_from_secret_same_as_cli(ops_test: OpsTest): + """Checking if password is same as returned by CLI. + + I.e. we're manipulating the secret we think we're manipulating. + """ + # + # No way to retrieve a secet by label for now (https://bugs.launchpad.net/juju/+bug/2037104) + # Therefore we take advantage of the fact, that we only have ONE single secret a this point + # So we take the single member of the list + # NOTE: This would BREAK if for instance units had secrets at the start... + # + password = await get_password(ops_test, username="replication") + complete_command = "list-secrets" + _, stdout, _ = await ops_test.juju(*complete_command.split()) + secret_id = stdout.split("\n")[1].split(" ")[0] + + # Getting back the pw from juju CLI + complete_command = f"show-secret {secret_id} --reveal --format=json" + _, stdout, _ = await ops_test.juju(*complete_command.split()) + data = json.loads(stdout) + assert data[secret_id]["content"]["Data"]["replication-password"] == password + + +async def test_empty_password(ops_test: OpsTest) -> None: + """Test that the password can't be set to an empty string.""" + leader_unit = await get_leader_unit(ops_test, APP_NAME) + leader = leader_unit.name + await set_password(ops_test, unit_name=leader, username="replication", password="") + password = await get_password(ops_test, unit_name=leader, username="replication") + # The password is 'None', BUT NOT because of SECRET_DELETED_LABEL + # `get_secret()` returns a None value (as the field in the secret is set to string value "None") + # And this true None value is turned to a string when the event is setting results. + assert password == "None" + + +async def test_no_password_change_on_invalid_password(ops_test: OpsTest) -> None: + """Test that in general, there is no change when password validation fails.""" + leader_unit = await get_leader_unit(ops_test, APP_NAME) + leader = leader_unit.name + password1 = await get_password(ops_test, username="replication") + # The password has to be minimum 3 characters + await set_password(ops_test, unit_name=leader, username="replication", password="ca" * 1000000) + password2 = await get_password(ops_test, username="replication") + # The password didn't change + assert password1 == password2 diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 59f5dca4ab..7d131cc077 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,30 +1,27 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. +import logging +import re import unittest from unittest.mock import MagicMock, Mock, PropertyMock, patch +import pytest from charms.postgresql_k8s.v0.postgresql import PostgreSQLUpdateUserPasswordError from lightkube.resources.core_v1 import Endpoints, Pod, Service from ops.model import ( ActiveStatus, BlockedStatus, MaintenanceStatus, - SecretNotFoundError, WaitingStatus, ) from ops.pebble import ServiceStatus from ops.testing import Harness +from parameterized import parameterized from tenacity import RetryError from charm import PostgresqlOperatorCharm -from constants import ( - PEER, - SECRET_CACHE_LABEL, - SECRET_DELETED_LABEL, - SECRET_INTERNAL_LABEL, - SECRET_LABEL, -) +from constants import PEER from tests.helpers import patch_network_get from tests.unit.helpers import _FakeApiError @@ -50,6 +47,10 @@ def setUp(self): self.rel_id = self.harness.add_relation(self._peer_relation, self.charm.app.name) + @pytest.fixture + def use_caplog(self, caplog): + self._caplog = caplog + @patch("charm.new_password", return_value="sekr1t") @patch("charm.PostgresqlOperatorCharm.get_secret", return_value=None) @patch("charm.PostgresqlOperatorCharm.set_secret") @@ -151,6 +152,7 @@ def test_on_postgresql_pebble_ready_no_connection(self, _, _rock_postgresql_vers mock_event.set_results.assert_not_called() self.assertIsInstance(self.harness.model.unit.status, MaintenanceStatus) + @pytest.mark.usefixtures("only_without_juju_secrets") def test_on_get_password(self): # Create a mock event and set passwords in peer relation data. mock_event = MagicMock(params={}) @@ -181,6 +183,31 @@ def test_on_get_password(self): self.charm._on_get_password(mock_event) mock_event.set_results.assert_called_once_with({"password": "replication-test-password"}) + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_get_password_secrets(self): + # Create a mock event and set passwords in peer relation data. + 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"}) + @patch("charm.Patroni.reload_patroni_configuration") @patch("charm.PostgresqlOperatorCharm.update_config") @patch("charm.PostgresqlOperatorCharm.set_secret") @@ -577,76 +604,32 @@ def test_scope_obj(self): assert self.charm._scope_obj("unit") == self.charm.framework.model.unit assert self.charm._scope_obj("test") is None + @parameterized.expand([("app"), ("unit")]) + @pytest.mark.usefixtures("only_without_juju_secrets") @patch("charm.Patroni.reload_patroni_configuration") @patch("charm.PostgresqlOperatorCharm._create_services") - def test_get_secret(self, _, __): + @patch("charm.PostgresqlOperatorCharm._cleanup_old_cluster_resources") + def test_get_secret(self, scope, _, __, ___): 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 + scope_obj = self.charm._scope_obj(scope) + assert self.charm.get_secret(scope, "password") is None self.harness.update_relation_data( - self.rel_id, self.charm.unit.name, {"password": "test-password"} + self.rel_id, scope_obj.name, {"password": "test-password"} ) - assert self.charm.get_secret("unit", "password") == "test-password" + assert self.charm.get_secret(scope, "password") == "test-password" - @patch("ops.charm.model.Model.get_secret") - @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) + @parameterized.expand([("app"), ("unit")]) + @pytest.mark.usefixtures("only_with_juju_secrets") @patch("charm.Patroni.reload_patroni_configuration") @patch("charm.PostgresqlOperatorCharm._create_services") - def test_get_secret_juju_error(self, _, __, ___, _get_secret): + @patch("charm.PostgresqlOperatorCharm._cleanup_old_cluster_resources") + def test_get_secret_secrets(self, scope, _, __, ___): self.harness.set_leader() - _get_secret.return_value.get_content.return_value = {"password": "test-password"} - - # clean the caches - if SECRET_INTERNAL_LABEL in self.charm.app_peer_data: - del self.charm.app_peer_data[SECRET_INTERNAL_LABEL] - self.charm.secrets["app"] = {} - - # general tests - self.harness.update_relation_data( - self.rel_id, self.charm.app.name, {SECRET_INTERNAL_LABEL: "secret_key"} - ) - _get_secret.side_effect = SecretNotFoundError - assert self.charm.get_secret("app", "password") is None - self.harness.update_relation_data(self.rel_id, self.charm.app.name, {}) - - @patch("ops.charm.model.Model.get_secret") - @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) - @patch("charm.Patroni.reload_patroni_configuration") - @patch("charm.PostgresqlOperatorCharm._create_services") - def test_get_secret_juju(self, _, __, ___, _get_secret): - self.harness.set_leader() - _get_secret.return_value.get_content.return_value = {"password": "test-password"} - - # clean the caches - if SECRET_INTERNAL_LABEL in self.charm.app_peer_data: - del self.charm.app_peer_data[SECRET_INTERNAL_LABEL] - self.charm.secrets["app"] = {} - - # Test application scope. - assert self.charm.get_secret("app", "password") is None - self.harness.update_relation_data( - self.rel_id, self.charm.app.name, {SECRET_INTERNAL_LABEL: "secret_key"} - ) - assert self.charm.get_secret("app", "password") == "test-password" - _get_secret.assert_called_once_with(id="secret_key") - - _get_secret.reset_mock() - # Test unit scope. - assert self.charm.get_secret("unit", "password") is None - self.harness.update_relation_data( - self.rel_id, self.charm.unit.name, {SECRET_INTERNAL_LABEL: "secret_key"} - ) - assert self.charm.get_secret("unit", "password") == "test-password" - _get_secret.assert_called_once_with(id="secret_key") + assert self.charm.get_secret(scope, "password") is None + assert self.charm.set_secret(scope, "password", "test-password") + assert self.charm.get_secret(scope, "password") == "test-password" @patch("charm.Patroni.reload_patroni_configuration") @patch("charm.PostgresqlOperatorCharm._create_services") @@ -667,48 +650,6 @@ def test_set_secret(self, _, __): self.charm.set_secret("unit", "password", None) assert self.charm.get_secret("unit", "password") is None - @patch("charm.JujuVersion.has_secrets", new_callable=PropertyMock, return_value=True) - @patch("charm.Patroni.reload_patroni_configuration") - @patch("charm.PostgresqlOperatorCharm._create_services") - def test_set_secret_juju(self, _, __, ___): - self.harness.set_leader() - secret_mock = Mock() - self.charm.secrets["app"][SECRET_LABEL] = secret_mock - self.charm.secrets["unit"][SECRET_LABEL] = secret_mock - self.charm.secrets["unit"][SECRET_CACHE_LABEL] = {} - - # Test application scope. - assert "password" not in self.charm.secrets["app"].get(SECRET_CACHE_LABEL, {}) - self.charm.set_secret("app", "password", "test-password") - assert self.charm.secrets["app"][SECRET_CACHE_LABEL]["password"] == "test-password" - secret_mock.set_content.assert_called_once_with( - self.charm.secrets["app"][SECRET_CACHE_LABEL] - ) - secret_mock.reset_mock() - - self.charm.set_secret("app", "password", None) - assert self.charm.secrets["app"][SECRET_CACHE_LABEL]["password"] == SECRET_DELETED_LABEL - secret_mock.set_content.assert_called_once_with( - self.charm.secrets["app"][SECRET_CACHE_LABEL] - ) - secret_mock.reset_mock() - - # Test unit scope. - assert "password" not in self.charm.secrets["unit"].get(SECRET_CACHE_LABEL, {}) - self.charm.set_secret("unit", "password", "test-password") - assert self.charm.secrets["unit"][SECRET_CACHE_LABEL]["password"] == "test-password" - secret_mock.set_content.assert_called_once_with( - self.charm.secrets["unit"][SECRET_CACHE_LABEL] - ) - secret_mock.reset_mock() - - self.charm.set_secret("unit", "password", None) - assert self.charm.secrets["unit"][SECRET_CACHE_LABEL]["password"] == SECRET_DELETED_LABEL - secret_mock.set_content.assert_called_once_with( - self.charm.secrets["unit"][SECRET_CACHE_LABEL] - ) - secret_mock.reset_mock() - @patch("charm.Client") def test_on_stop(self, _client): # Test a successful run of the hook. @@ -774,3 +715,126 @@ def test_on_stop(self, _client): self.charm.on.stop.emit() self.assertEqual(_client.return_value.apply.call_count, 2) self.assertIn("failed to patch k8s MagicMock", "".join(logs.output)) + + @parameterized.expand([("app"), ("unit")]) + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_secret_returning_secret_label(self, scope): + secret_id = self.harness.charm.set_secret(scope, "somekey", "bla") + assert re.match(f"{self.harness.charm.app.name}.{scope}", secret_id) + + @parameterized.expand([("app"), ("unit")]) + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_reset_new_secret(self, scope): + """NOTE: currently ops.testing seems to allow for non-leader to set secrets too!""" + # 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"), ("unit")]) + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_invalid_secret(self, scope): + with self.assertRaises(TypeError): + self.harness.charm.set_secret("unit", "somekey", 1) + + self.harness.charm.set_secret("unit", "somekey", "") + assert self.harness.charm.get_secret(scope, "somekey") is None + + @pytest.mark.usefixtures("only_without_juju_secrets") + @pytest.mark.usefixtures("use_caplog") + def test_delete_password(self): + """NOTE: currently ops.testing seems to allow for non-leader to remove secrets too!""" + 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.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 + + with self._caplog.at_level(logging.ERROR): + self.harness.charm.remove_secret("app", "replication") + assert ( + "Non-existing secret app:replication was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "somekey") + assert ( + "Non-existing secret unit:somekey was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("app", "non-existing-secret") + assert ( + "Non-existing secret app:non-existing-secret was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "non-existing-secret") + assert ( + "Non-existing secret unit:non-existing-secret was attempted to be removed." + in self._caplog.text + ) + + @pytest.mark.usefixtures("only_with_juju_secrets") + @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!""" + assert self.harness.charm.set_secret("app", "replication", "somepw") + self.harness.charm.remove_secret("app", "replication") + assert self.harness.charm.get_secret("app", "replication") is None + + assert self.harness.charm.set_secret("unit", "somekey", "somesecret") + self.harness.charm.remove_secret("unit", "somekey") + assert self.harness.charm.get_secret("unit", "somekey") is None + + with self._caplog.at_level(logging.ERROR): + self.harness.charm.remove_secret("app", "replication") + assert ( + "Non-existing secret app:replication was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "somekey") + assert ( + "Non-existing secret unit:somekey was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("app", "non-existing-secret") + assert ( + "Non-existing secret app:non-existing-secret was attempted to be removed." + in self._caplog.text + ) + + self.harness.charm.remove_secret("unit", "non-existing-secret") + assert ( + "Non-existing secret unit:non-existing-secret was attempted to be removed." + in self._caplog.text + ) + + @parameterized.expand([("app"), ("unit")]) + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_migartion(self, scope): + """Check if we're moving on to use secrets when live upgrade from databag to Secrets usage.""" + # Getting current password + entity = getattr(self.charm, scope) + self.harness.update_relation_data(self.rel_id, entity.name, {"my-secret": "bla"}) + assert self.harness.charm.get_secret(scope, "my-secret") == "bla" + + # Reset new secret + secret_label = self.harness.charm.set_secret(scope, "my-secret", "blablabla") + assert self.harness.charm.model.get_secret(label=secret_label) + assert self.harness.charm.get_secret(scope, "my-secret") == "blablabla"