Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions lib/charms/data_platform_libs/v0/data_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,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 = 41
LIBPATCH = 42

PYDEPS = ["ops>=2.0.0"]

Expand Down Expand Up @@ -960,6 +960,7 @@ class Data(ABC):
"username": SECRET_GROUPS.USER,
"password": SECRET_GROUPS.USER,
"uris": SECRET_GROUPS.USER,
"read-only-uris": SECRET_GROUPS.USER,
"tls": SECRET_GROUPS.TLS,
"tls-ca": SECRET_GROUPS.TLS,
}
Expand Down Expand Up @@ -1700,7 +1701,7 @@ def set_tls_ca(self, relation_id: int, tls_ca: str) -> None:
class RequirerData(Data):
"""Requirer-side of the relation."""

SECRET_FIELDS = ["username", "password", "tls", "tls-ca", "uris"]
SECRET_FIELDS = ["username", "password", "tls", "tls-ca", "uris", "read-only-uris"]

def __init__(
self,
Expand Down Expand Up @@ -2368,7 +2369,7 @@ def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None:
self.secret_fields,
fields,
self._update_relation_secret,
data={field: self.deleted_label for field in fields},
data=dict.fromkeys(fields, self.deleted_label),
)
else:
_, normal_fields = self._process_secret_fields(
Expand Down Expand Up @@ -2749,6 +2750,19 @@ def uris(self) -> Optional[str]:

return self.relation.data[self.relation.app].get("uris")

@property
def read_only_uris(self) -> Optional[str]:
"""Returns the readonly connection URIs."""
if not self.relation.app:
return None

if self.secrets_enabled:
secret = self._get_secret("user")
if secret:
return secret.get("read-only-uris")

return self.relation.data[self.relation.app].get("read-only-uris")

@property
def version(self) -> Optional[str]:
"""Returns the version of the database.
Expand Down Expand Up @@ -2855,6 +2869,15 @@ def set_uris(self, relation_id: int, uris: str) -> None:
"""
self.update_relation_data(relation_id, {"uris": uris})

def set_read_only_uris(self, relation_id: int, uris: str) -> None:
"""Set the database readonly connection URIs in the application relation databag.

Args:
relation_id: the identifier for a particular relation.
uris: connection URIs.
"""
self.update_relation_data(relation_id, {"read-only-uris": uris})

def set_version(self, relation_id: int, version: str) -> None:
"""Set the database version in the application relation databag.

Expand Down
17 changes: 16 additions & 1 deletion src/relations/postgresql_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,12 @@ def update_endpoints(self, event: DatabaseRequestedEvent = None) -> None:
read_only_endpoints = (
",".join(f"{x}:{DATABASE_PORT}" for x in replicas_endpoint)
if len(replicas_endpoint) > 0
else ""
else f"{self.charm.primary_endpoint}:{DATABASE_PORT}"
)
read_only_hosts = (
",".join(replicas_endpoint)
if len(replicas_endpoint) > 0
else f"{self.charm.primary_endpoint}"
)

tls = "True" if self.charm.is_tls_enabled else "False"
Expand Down Expand Up @@ -235,6 +240,16 @@ def update_endpoints(self, event: DatabaseRequestedEvent = None) -> None:
relation_id,
f"postgresql://{user}:{password}@{self.charm.primary_endpoint}:{DATABASE_PORT}/{database}",
)
# Make sure that the URI will be a secret
if (
secret_fields := self.database_provides.fetch_relation_field(
relation_id, "requested-secrets"
)
) and "read-only-uris" in secret_fields:
self.database_provides.set_read_only_uris(
relation_id,
f"postgresql://{user}:{password}@{read_only_hosts}:{DATABASE_PORT}/{database}",
)

self.database_provides.set_tls(relation_id, tls)
self.database_provides.set_tls_ca(relation_id, ca)
Expand Down
14 changes: 7 additions & 7 deletions tests/integration/ha_tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,21 +954,21 @@ async def add_unit_with_storage(ops_test, app, storage):
Note: this function exists as a temporary solution until this issue is resolved:
https://github.com/juju/python-libjuju/issues/695
"""
expected_units = len(ops_test.model.applications[app].units) + 1
prev_units = [unit.name for unit in ops_test.model.applications[app].units]
original_units = {unit.name for unit in ops_test.model.applications[app].units}
model_name = ops_test.model.info.name
add_unit_cmd = f"add-unit {app} --model={model_name} --attach-storage={storage}".split()
return_code, _, _ = await ops_test.juju(*add_unit_cmd)
assert return_code == 0, "Failed to add unit with storage"
async with ops_test.fast_forward():
await ops_test.model.wait_for_idle(apps=[app], status="active", timeout=2000)
assert len(ops_test.model.applications[app].units) == expected_units, (
"New unit not added to model"
)

# When removing all units sometimes the last unit remain in the list
current_units = {unit.name for unit in ops_test.model.applications[app].units}
original_units.intersection_update(current_units)
assert original_units.issubset(current_units), "New unit not added to model"
Comment on lines +965 to +968
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoying failure in the restore cluster test. Last unit remains in the list of units when scaling down to zero.


# verify storage attached
curr_units = [unit.name for unit in ops_test.model.applications[app].units]
new_unit = next(unit for unit in set(curr_units) - set(prev_units))
new_unit = (current_units - original_units).pop()
assert storage_id(ops_test, new_unit) == storage, "unit added with incorrect storage"

# return a reference to newly added unit
Expand Down
42 changes: 0 additions & 42 deletions tests/integration/new_relations/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import yaml
from pytest_operator.plugin import OpsTest
from tenacity import RetryError, Retrying, stop_after_attempt, wait_exponential


async def get_juju_secret(ops_test: OpsTest, secret_uri: str) -> dict[str, str]:
Expand Down Expand Up @@ -79,47 +78,6 @@ async def build_connection_string(
return f"dbname='{database}' user='{username}' host='{host}' password='{password}' connect_timeout=10"


async def check_relation_data_existence(
ops_test: OpsTest,
application_name: str,
relation_name: str,
key: str,
exists: bool = True,
) -> bool:
"""Checks for the existence of a key in the relation data.

Args:
ops_test: The ops test framework instance
application_name: The name of the application
relation_name: Name of the relation to get relation data from
key: Key of data to be checked
exists: Whether to check for the existence or non-existence

Returns:
whether the key exists in the relation data
"""
try:
# Retry mechanism used to wait for some events to be triggered,
# like the relation departed event.
for attempt in Retrying(
stop=stop_after_attempt(10), wait=wait_exponential(multiplier=1, min=2, max=30)
):
with attempt:
data = await get_application_relation_data(
ops_test,
application_name,
relation_name,
key,
)
if exists:
assert data is not None
else:
assert data is None
return True
except RetryError:
return False


async def get_alias_from_relation_data(
ops_test: OpsTest, unit_name: str, related_unit_name: str
) -> str | None:
Expand Down
48 changes: 29 additions & 19 deletions tests/integration/new_relations/test_new_relations_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from ..juju_ import juju_major_version
from .helpers import (
build_connection_string,
check_relation_data_existence,
get_application_relation_data,
)

Expand Down Expand Up @@ -80,7 +79,7 @@ async def test_deploy_charms(ops_test: OpsTest, charm):
await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", timeout=3000)


async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest):
async def test_primary_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest):
"""Test that there is no read-only endpoint in a standalone cluster."""
async with ops_test.fast_forward():
# Ensure the cluster starts with only one member.
Expand Down Expand Up @@ -116,31 +115,42 @@ async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest):
assert password is None

# Try to get the connection string of the database using the read-only endpoint.
# It should not be available.
assert await check_relation_data_existence(
ops_test,
APPLICATION_APP_NAME,
FIRST_DATABASE_RELATION_NAME,
"read-only-endpoints",
exists=False,
)
# It should be the primary.
primary_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0]
for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(3), reraise=True):
with attempt:
data = await get_application_relation_data(
ops_test,
APPLICATION_APP_NAME,
FIRST_DATABASE_RELATION_NAME,
"read-only-endpoints",
)
assert data == f"{primary_unit.public_address}:5432"


async def test_read_only_endpoint_in_scaled_up_cluster(ops_test: OpsTest):
"""Test that there is read-only endpoint in a scaled up cluster."""
async with ops_test.fast_forward():
# Scale up the database.
await scale_application(ops_test, DATABASE_APP_NAME, 2)
primary = await get_primary(ops_test, f"{DATABASE_APP_NAME}/0")
replica = next(
unit
for unit in ops_test.model.applications[DATABASE_APP_NAME].units
if unit.name != primary
)

# Try to get the connection string of the database using the read-only endpoint.
# It should be available again.
assert await check_relation_data_existence(
ops_test,
APPLICATION_APP_NAME,
FIRST_DATABASE_RELATION_NAME,
"read-only-endpoints",
exists=True,
)
# It should be the replica unit.
for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(3), reraise=True):
with attempt:
data = await get_application_relation_data(
ops_test,
APPLICATION_APP_NAME,
FIRST_DATABASE_RELATION_NAME,
"read-only-endpoints",
)
assert data == f"{replica.public_address}:5432"


async def test_database_relation_with_charm_libraries(ops_test: OpsTest):
Expand Down Expand Up @@ -212,7 +222,7 @@ async def test_filter_out_degraded_replicas(ops_test: OpsTest):
FIRST_DATABASE_RELATION_NAME,
"read-only-endpoints",
)
assert data is None
assert data == f"{ops_test.model.units[primary].public_address}:5432"

await start_machine(ops_test, machine)
await ops_test.model.wait_for_idle(
Expand Down
5 changes: 2 additions & 3 deletions tests/integration/test_backups_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from pytest_operator.plugin import OpsTest
from tenacity import Retrying, stop_after_attempt, wait_exponential

from . import architecture
from .conftest import AWS
from .helpers import (
DATABASE_APP_NAME,
Expand All @@ -28,11 +27,11 @@
S3_INTEGRATOR_APP_NAME = "s3-integrator"
if juju_major_version < 3:
tls_certificates_app_name = "tls-certificates-operator"
tls_channel = "legacy/edge" if architecture.architecture == "arm64" else "legacy/stable"
tls_channel = "legacy/stable"
tls_config = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
else:
tls_certificates_app_name = "self-signed-certificates"
tls_channel = "latest/edge" if architecture.architecture == "arm64" else "latest/stable"
tls_channel = "latest/stable"
tls_config = {"ca-common-name": "Test CA"}

logger = logging.getLogger(__name__)
Expand Down
6 changes: 3 additions & 3 deletions tests/integration/test_backups_ceph.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import pytest
from pytest_operator.plugin import OpsTest

from . import architecture, markers
from . import markers
from .helpers import (
backup_operations,
)
Expand All @@ -25,11 +25,11 @@
S3_INTEGRATOR_APP_NAME = "s3-integrator"
if juju_major_version < 3:
tls_certificates_app_name = "tls-certificates-operator"
tls_channel = "legacy/edge" if architecture.architecture == "arm64" else "legacy/stable"
tls_channel = "legacy/stable"
tls_config = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
else:
tls_certificates_app_name = "self-signed-certificates"
tls_channel = "latest/edge" if architecture.architecture == "arm64" else "latest/stable"
tls_channel = "latest/stable"
tls_config = {"ca-common-name": "Test CA"}

backup_id, value_before_backup, value_after_backup = "", None, None
Expand Down
5 changes: 2 additions & 3 deletions tests/integration/test_backups_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from pytest_operator.plugin import OpsTest
from tenacity import Retrying, stop_after_attempt, wait_exponential

from . import architecture
from .conftest import GCP
from .helpers import (
CHARM_BASE,
Expand All @@ -28,11 +27,11 @@
S3_INTEGRATOR_APP_NAME = "s3-integrator"
if juju_major_version < 3:
tls_certificates_app_name = "tls-certificates-operator"
tls_channel = "legacy/edge" if architecture.architecture == "arm64" else "legacy/stable"
tls_channel = "legacy/stable"
tls_config = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
else:
tls_certificates_app_name = "self-signed-certificates"
tls_channel = "latest/edge" if architecture.architecture == "arm64" else "latest/stable"
tls_channel = "latest/stable"
tls_config = {"ca-common-name": "Test CA"}

logger = logging.getLogger(__name__)
Expand Down
9 changes: 4 additions & 5 deletions tests/integration/test_backups_pitr_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from pytest_operator.plugin import OpsTest
from tenacity import Retrying, stop_after_attempt, wait_exponential

from . import architecture
from .conftest import AWS
from .helpers import (
CHARM_BASE,
Expand All @@ -23,11 +22,11 @@
S3_INTEGRATOR_APP_NAME = "s3-integrator"
if juju_major_version < 3:
TLS_CERTIFICATES_APP_NAME = "tls-certificates-operator"
TLS_CHANNEL = "legacy/edge" if architecture.architecture == "arm64" else "legacy/stable"
TLS_CHANNEL = "legacy/stable"
TLS_CONFIG = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
else:
TLS_CERTIFICATES_APP_NAME = "self-signed-certificates"
TLS_CHANNEL = "latest/edge" if architecture.architecture == "arm64" else "latest/stable"
TLS_CHANNEL = "latest/stable"
TLS_CONFIG = {"ca-common-name": "Test CA"}

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -326,10 +325,10 @@ async def pitr_backup_operations(

@pytest.mark.abort_on_fail
async def test_pitr_backup_aws(
ops_test: OpsTest, gcp_cloud_configs: tuple[dict, dict], charm
ops_test: OpsTest, aws_cloud_configs: tuple[dict, dict], charm
) -> None:
"""Build, deploy two units of PostgreSQL and do backup in AWS. Then, write new data into DB, switch WAL file and test point-in-time-recovery restore action."""
config, credentials = gcp_cloud_configs
config, credentials = aws_cloud_configs

await pitr_backup_operations(
ops_test,
Expand Down
5 changes: 2 additions & 3 deletions tests/integration/test_backups_pitr_gcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from pytest_operator.plugin import OpsTest
from tenacity import Retrying, stop_after_attempt, wait_exponential

from . import architecture
from .conftest import GCP
from .helpers import (
CHARM_BASE,
Expand All @@ -23,11 +22,11 @@
S3_INTEGRATOR_APP_NAME = "s3-integrator"
if juju_major_version < 3:
TLS_CERTIFICATES_APP_NAME = "tls-certificates-operator"
TLS_CHANNEL = "legacy/edge" if architecture.architecture == "arm64" else "legacy/stable"
TLS_CHANNEL = "legacy/stable"
TLS_CONFIG = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"}
else:
TLS_CERTIFICATES_APP_NAME = "self-signed-certificates"
TLS_CHANNEL = "latest/edge" if architecture.architecture == "arm64" else "latest/stable"
TLS_CHANNEL = "latest/stable"
TLS_CONFIG = {"ca-common-name": "Test CA"}

logger = logging.getLogger(__name__)
Expand Down
Loading