Skip to content

Commit 1927fac

Browse files
committed
# This is a combination of 7 commits.
# This is the 1st commit message: feat: DPV1 # The commit message #2 will be skipped: # feat: unit tests + linting # The commit message #3 will be skipped: # fix: linting # The commit message #4 will be skipped: # fix: enable tests # The commit message #5 will be skipped: # fix: only correct tests # The commit message #6 will be skipped: # fix: application deployed # The commit message #7 will be skipped: # fix: build charms
1 parent 43befd0 commit 1927fac

File tree

60 files changed

+7462
-66
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+7462
-66
lines changed

.github/workflows/ci.yaml

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ jobs:
4040
juju-version:
4141
- libjuju-version: "2.9.49.1"
4242
- libjuju-version: "3.6.1.0"
43+
exclude:
44+
- libs-version: 1
45+
juju-version: {libjuju-version: "2.9.49.1"}
4346
steps:
4447
- name: Checkout
4548
uses: actions/checkout@v4
@@ -51,7 +54,7 @@ jobs:
5154
env:
5255
LIBJUJU_VERSION_SPECIFIER: "==${{ matrix.juju-version.libjuju-version }}"
5356

54-
integration-test:
57+
integration-test-v0:
5558
strategy:
5659
fail-fast: false
5760
matrix:
@@ -155,3 +158,73 @@ jobs:
155158
with:
156159
app: data-platform-libs
157160
model: testing
161+
162+
integration-test-v1:
163+
strategy:
164+
fail-fast: false
165+
matrix:
166+
ubuntu-versions:
167+
# Update whenever charmcraft.yaml is changed
168+
- series: jammy
169+
bases-index: 0
170+
- series: noble
171+
bases-index: 1
172+
tox-environments:
173+
- integration-db-v1
174+
- integration-opensearch-v1
175+
- integration-kafka-v1
176+
- integration-kafka-connect-v1
177+
- integration-backward-compatibility-v1
178+
juju-version:
179+
- juju-bootstrap-option: "3.6.1"
180+
juju-snap-channel: "3.6/stable"
181+
libjuju-version: "3.6.1.0"
182+
name: V1 -- ${{ matrix.tox-environments }} Juju ${{ matrix.juju-version.juju-snap-channel}} -- ${{ matrix.ubuntu-versions.series }}
183+
needs:
184+
- lint
185+
- unit-test
186+
runs-on: ubuntu-latest
187+
timeout-minutes: 120
188+
steps:
189+
- name: Checkout
190+
uses: actions/checkout@v4
191+
- name: Setup operator environment
192+
# TODO: Replace with custom image on self-hosted runner
193+
uses: charmed-kubernetes/actions-operator@main
194+
with:
195+
provider: microk8s
196+
channel: "1.27-strict/stable"
197+
bootstrap-options: "--agent-version ${{ matrix.juju-version.juju-bootstrap-option }}"
198+
juju-channel: ${{ matrix.juju-version.juju-snap-channel }}
199+
charmcraft-channel: "3.x/stable"
200+
- name: Download packed charm(s)
201+
uses: actions/download-artifact@v4
202+
with:
203+
name: ${{ needs.build.outputs.artifact-name }}
204+
- name: Select tests
205+
id: select-tests
206+
run: |
207+
if [ "${{ github.event_name }}" == "schedule" ]
208+
then
209+
echo Running unstable and stable tests
210+
echo "mark_expression=" >> $GITHUB_OUTPUT
211+
else
212+
echo Skipping unstable tests
213+
echo "mark_expression=not unstable" >> $GITHUB_OUTPUT
214+
fi
215+
- name: Run integration tests
216+
# set a predictable model name so it can be consumed by charm-logdump-action
217+
run: tox run -e ${{ matrix.tox-environments }} -- -m '${{ steps.select-tests.outputs.mark_expression }}' --model testing --os-series=${{ matrix.ubuntu-versions.series }} --build-bases-index=${{ matrix.ubuntu-versions.bases-index }}
218+
env:
219+
CI_PACKED_CHARMS: ${{ needs.build.outputs.charms }}
220+
LIBJUJU_VERSION_SPECIFIER: "==${{ matrix.juju-version.libjuju-version }}"
221+
WEBSOCKETS_VERSION_SPECIFIER: ${{ env.WEBSOCKETS_VERSION_SPECIFIER }}
222+
- name: Print debug-log
223+
if: failure()
224+
run: juju switch testing; juju debug-log --replay --no-tail
225+
- name: Dump logs
226+
uses: canonical/charm-logdump-action@main
227+
if: failure()
228+
with:
229+
app: data-platform-libs
230+
model: testing

lib/charms/data_platform_libs/v1/data_interfaces.py

Lines changed: 129 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def _on_cluster1_resource_created(self, event: ResourceCreatedEvent) -> None:
170170
171171
# Create configuration file for app
172172
config_file = self._render_app_config_file(
173-
event.respones.username,
173+
event.response.username,
174174
event.response.password,
175175
event.response.endpoints,
176176
)
@@ -286,6 +286,11 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
286286
from pydantic_core import CoreSchema, core_schema
287287
from typing_extensions import TypeAliasType, override
288288

289+
try:
290+
import psycopg
291+
except ImportError:
292+
psycopg = None
293+
289294
# The unique Charmhub library identifier, never change it
290295
LIBID = "6c3e6b6680d64e9c89e611d1a15f65be"
291296

@@ -693,31 +698,36 @@ def serialize_model(self, handler: SerializerFunctionWrapHandler, info: Serializ
693698
if not secret_group:
694699
raise SecretsUnavailableError(field)
695700

696-
if (value := getattr(self, field)) is None:
697-
continue
698-
699701
aliased_field = field_info.serialization_alias or field
700702
secret = repository.get_secret(secret_group, secret_uri=None)
703+
704+
value = getattr(self, field)
705+
701706
actual_value = (
702707
value.get_secret_value() if issubclass(value.__class__, _SecretBase) else value
703708
)
704-
if not isinstance(actual_value, str):
705-
actual_value = json.dumps(actual_value)
706709

707-
if secret:
708-
content = secret.get_content()
709-
full_content = copy.deepcopy(content)
710-
full_content.update({aliased_field: actual_value})
711-
secret.set_content(full_content)
712-
else:
713-
secret = repository.add_secret(
714-
aliased_field,
715-
actual_value,
716-
secret_group,
717-
)
718-
if not secret or not secret.meta:
719-
raise SecretError("No secret to send back")
710+
if secret is None:
711+
if actual_value:
712+
secret = repository.add_secret(
713+
aliased_field,
714+
actual_value,
715+
secret_group,
716+
)
717+
if not secret or not secret.meta:
718+
raise SecretError("No secret to send back")
719+
continue
720720

721+
content = secret.get_content()
722+
full_content = copy.deepcopy(content)
723+
724+
if actual_value is None:
725+
full_content.pop(field, None)
726+
else:
727+
if not isinstance(actual_value, str):
728+
actual_value = json.dumps(actual_value)
729+
full_content.update({aliased_field: actual_value})
730+
secret.set_content(full_content)
721731
return handler(self)
722732

723733

@@ -783,6 +793,7 @@ def extract_secrets(self, info: ValidationInfo):
783793
@model_serializer(mode="wrap")
784794
def serialize_model(self, handler: SerializerFunctionWrapHandler, info: SerializationInfo):
785795
"""Serializes the model writing the secrets in their respective secrets."""
796+
_encountered_secrets: set[tuple[CachedSecret, str]] = set()
786797
if not info.context or not isinstance(info.context.get("repository"), AbstractRepository):
787798
logger.debug("No secret parsing serialization as we're lacking context here.")
788799
return handler(self)
@@ -797,8 +808,6 @@ def serialize_model(self, handler: SerializerFunctionWrapHandler, info: Serializ
797808
secret_group = field_info.metadata[0]
798809
if not secret_group:
799810
raise SecretsUnavailableError(field)
800-
if (value := getattr(self, field)) is None:
801-
continue
802811
aliased_field = field_info.serialization_alias or field
803812
secret_field = repository.secret_field(secret_group, aliased_field).replace(
804813
"-", "_"
@@ -807,24 +816,41 @@ def serialize_model(self, handler: SerializerFunctionWrapHandler, info: Serializ
807816
secret = repository.get_secret(
808817
secret_group, secret_uri=secret_uri, short_uuid=short_uuid
809818
)
819+
820+
value = getattr(self, field)
821+
810822
actual_value = (
811823
value.get_secret_value() if issubclass(value.__class__, _SecretBase) else value
812824
)
813-
if not isinstance(actual_value, str):
814-
actual_value = json.dumps(actual_value)
815825

816-
if secret:
817-
content = secret.get_content()
818-
full_content = copy.deepcopy(content)
819-
full_content.update({aliased_field: actual_value})
820-
secret.set_content(full_content)
826+
if secret is None:
827+
if actual_value:
828+
secret = repository.add_secret(
829+
aliased_field, actual_value, secret_group, short_uuid
830+
)
831+
if not secret or not secret.meta:
832+
raise SecretError("No secret to send back")
833+
setattr(self, secret_field, secret.meta.id)
834+
continue
835+
836+
content = secret.get_content()
837+
full_content = copy.deepcopy(content)
838+
839+
if actual_value is None:
840+
full_content.pop(field, None)
841+
_encountered_secrets.add((secret, secret_field))
821842
else:
822-
secret = repository.add_secret(
823-
aliased_field, actual_value, secret_group, short_uuid
824-
)
825-
if not secret or not secret.meta:
826-
raise SecretError("No secret to send back")
827-
setattr(self, secret_field, secret.meta.id)
843+
if not isinstance(actual_value, str):
844+
actual_value = json.dumps(actual_value)
845+
full_content.update({aliased_field: actual_value})
846+
secret.set_content(full_content)
847+
848+
# Delete all empty secrets and clean up their fields.
849+
for secret, secret_field in _encountered_secrets:
850+
if not secret.get_content():
851+
# Setting a field to '' deletes it
852+
setattr(self, secret_field, "")
853+
repository.delete_secret(secret.label)
828854

829855
return handler(self)
830856

@@ -1044,7 +1070,7 @@ def write_fields(self, mapping: dict[str, Any]) -> None:
10441070
...
10451071

10461072
def write_secret_field(
1047-
self, field: str, value: Any, group: SecretGroup, uri_to_databag: bool = False
1073+
self, field: str, value: Any, group: SecretGroup
10481074
) -> CachedSecret | None:
10491075
"""Writes a secret field."""
10501076
...
@@ -1060,6 +1086,11 @@ def add_secret(
10601086
"""Gets a value for a field stored in a secret group."""
10611087
...
10621088

1089+
@abstractmethod
1090+
def delete_secret(self, label: str):
1091+
"""Deletes a secret by its label."""
1092+
...
1093+
10631094
@abstractmethod
10641095
def delete_field(self, field: str) -> None:
10651096
"""Deletes a field."""
@@ -1390,6 +1421,11 @@ def add_secret(
13901421

13911422
return secret
13921423

1424+
@override
1425+
@ensure_leader_for_app
1426+
def delete_secret(self, label: str) -> None:
1427+
self.secrets.remove(label)
1428+
13931429

13941430
@final
13951431
class OpsRelationRepository(OpsRepository):
@@ -1841,13 +1877,11 @@ class ResourceProvidesEvents(CharmEvents, Generic[TRequirerCommonModel]):
18411877
This class defines the events that the database can emit.
18421878
"""
18431879

1844-
bulk_resources_requested = EventSource(BulkResourcesRequestedEvent[TRequirerCommonModel])
1845-
resource_requested = EventSource(ResourceRequestedEvent[TRequirerCommonModel])
1846-
resource_entity_requested = EventSource(ResourceEntityRequestedEvent[TRequirerCommonModel])
1847-
resource_entity_permissions_changed = EventSource(
1848-
ResourceEntityPermissionsChangedEvent[TRequirerCommonModel]
1849-
)
1850-
mtls_cert_updated = EventSource(MtlsCertUpdatedEvent[TRequirerCommonModel])
1880+
bulk_resources_requested = EventSource(BulkResourcesRequestedEvent)
1881+
resource_requested = EventSource(ResourceRequestedEvent)
1882+
resource_entity_requested = EventSource(ResourceEntityRequestedEvent)
1883+
resource_entity_permissions_changed = EventSource(ResourceEntityPermissionsChangedEvent)
1884+
mtls_cert_updated = EventSource(MtlsCertUpdatedEvent)
18511885

18521886

18531887
class ResourceRequirerEvent(EventBase, Generic[TResourceProviderModel]):
@@ -1934,12 +1968,10 @@ class ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]):
19341968
This class defines the events that the database can emit.
19351969
"""
19361970

1937-
resource_created = EventSource(ResourceCreatedEvent[TResourceProviderModel])
1938-
resource_entity_created = EventSource(ResourceEntityCreatedEvent[TResourceProviderModel])
1939-
endpoints_changed = EventSource(ResourceEndpointsChangedEvent[TResourceProviderModel])
1940-
read_only_endpoints_changed = EventSource(
1941-
ResourceReadOnlyEndpointsChangedEvent[TResourceProviderModel]
1942-
)
1971+
resource_created = EventSource(ResourceCreatedEvent)
1972+
resource_entity_created = EventSource(ResourceEntityCreatedEvent)
1973+
endpoints_changed = EventSource(ResourceEndpointsChangedEvent)
1974+
read_only_endpoints_changed = EventSource(ResourceReadOnlyEndpointsChangedEvent)
19431975

19441976

19451977
##############################################################################
@@ -2027,7 +2059,6 @@ def compute_diff(
20272059
new_data = request.model_dump(
20282060
mode="json",
20292061
exclude={"data"},
2030-
context={"repository": repository},
20312062
exclude_none=True,
20322063
exclude_defaults=True,
20332064
)
@@ -2154,7 +2185,7 @@ def _handle_bulk_event(
21542185
This allows for the developer to process the diff and store it themselves
21552186
"""
21562187
for request in request_model.requests:
2157-
# Compute the diff withtout storing it so we can validate the diffs.
2188+
# Compute the diff without storing it so we can validate the diffs.
21582189
_diff = self.compute_diff(event.relation, request, repository, store=False)
21592190
self._validate_diff(event, _diff)
21602191

@@ -2435,6 +2466,54 @@ def are_all_resources_created(self, rel_id: int) -> bool:
24352466
if request.request_id
24362467
)
24372468

2469+
@staticmethod
2470+
def _is_pg_plugin_enabled(plugin: str, connection_string: str) -> bool:
2471+
# Actual checking method.
2472+
# No need to check for psycopg here, it's been checked before.
2473+
if not psycopg:
2474+
return False
2475+
2476+
try:
2477+
with psycopg.connect(connection_string) as connection:
2478+
with connection.cursor() as cursor:
2479+
cursor.execute(
2480+
"SELECT TRUE FROM pg_extension WHERE extname=%s::text;", (plugin,)
2481+
)
2482+
return cursor.fetchone() is not None
2483+
except psycopg.Error as e:
2484+
logger.exception(
2485+
f"failed to check whether {plugin} plugin is enabled in the database: %s",
2486+
str(e),
2487+
)
2488+
return False
2489+
2490+
def is_postgresql_plugin_enabled(self, plugin: str, relation_id: int = 0) -> bool:
2491+
"""Returns whether a plugin is enabled in the database.
2492+
2493+
Args:
2494+
plugin: name of the plugin to check.
2495+
relation_id: Optional index to check the database (default: 0 - first relation).
2496+
"""
2497+
if not psycopg:
2498+
return False
2499+
2500+
# Can't check a non existing relation.
2501+
if len(self.relations) <= relation_id:
2502+
return False
2503+
2504+
relation_id = self.relations[relation_id].id
2505+
model = self.interface.build_model(relation_id=relation_id)
2506+
for request in model.requests:
2507+
if request.endpoints and request.username and request.password:
2508+
host = request.endpoints.split(":")[0]
2509+
username = request.username.get_secret_value()
2510+
password = request.password.get_secret_value()
2511+
2512+
connection_string = f"host='{host}' dbname='{request.resource}' user='{username}' password='{password}'"
2513+
return self._is_pg_plugin_enabled(plugin, connection_string)
2514+
logger.info("No valid request to use to check for plugin.")
2515+
return False
2516+
24382517
##############################################################################
24392518
# Helpers for aliases
24402519
##############################################################################

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ ignore = ["E501", "D107"]
6666
per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104", "E999"]}
6767

6868
[tool.ruff.lint.mccabe]
69-
max-complexity = 12
69+
max-complexity = 13
7070

7171
[tool.pyright]
7272
include = ["src", "lib"]

requirements/v1/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
ops >= 2.1.1
2-
pydantic>=2,<3
2+
pydantic>=2.11,<3
File renamed without changes.

0 commit comments

Comments
 (0)