@@ -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:
286286from pydantic_core import CoreSchema , core_schema
287287from 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
290295LIBID = "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
13951431class 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
18531887class 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 ##############################################################################
0 commit comments