From 7b00660a722a5aec185b150623d22c1dd8f5a467 Mon Sep 17 00:00:00 2001 From: James Garner Date: Thu, 3 Sep 2026 17:46:48 +1200 Subject: [PATCH] chore(interfaces): move the data-bigdata interface schemas to the new format Interface schemas no longer wrap their models in the `ProviderSchema` and `RequirerSchema` classes from `pytest-interface-tester`. Instead a schema names all four databags directly, as `ProviderAppData`, `ProviderUnitData`, `RequirerAppData` and `RequirerUnitData`, setting a databag that is always empty to `None`. Naming all four makes an empty databag a deliberate choice a reviewer can see, and makes a misspelled name an error rather than a silently empty databag. The models are unchanged; only the wrapper classes around them are gone. Dropping the `pytest-interface-tester` import leaves Pydantic as the only dependency needed to read a schema. The new format is designed to be consumed by `ops.Relation.load` and to round-trip with `ops.Relation.save`, so the schemas define custom encoders and decoders (since `ops` assumes JSON encoding) and add Pydantic aliases for the hyphenated databag keys. A follow-up PR documents the format and adds a CI check for it, once every interface has been migrated. Co-Authored-By: Claude Opus 5 (1M context) --- .../connect_client/interface/v0/schema.py | 60 ++++++++++----- .../kafka_client/interface/v0/schema.py | 74 ++++++++++--------- .../karapace_client/interface/v0/schema.py | 53 +++++++------ 3 files changed, 111 insertions(+), 76 deletions(-) diff --git a/interfaces/connect_client/interface/v0/schema.py b/interfaces/connect_client/interface/v0/schema.py index 5804153ea..00dd987fb 100644 --- a/interfaces/connect_client/interface/v0/schema.py +++ b/interfaces/connect_client/interface/v0/schema.py @@ -1,17 +1,32 @@ -"""This file defines the schemas for the provider and requirer sides of `connect_client` charm relation interface. +"""This file defines the schemas for the provider and requirer sides of `connect_client` charm relation interface.""" -It exposes two interfaces.schema_base.DataBagSchema subclasses called: -- ProviderSchema -- RequirerSchema -""" +import json +from typing import Any -from interface_tester.schema_base import DataBagSchema -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator PLUGIN_URL_NOT_REQUIRED = "NOT-REQUIRED" -class ConnectProviderData(BaseModel): +class _BareStringDatabag(BaseModel): + """Base class for databag models that don't strictly JSON encode all entries.""" + + @staticmethod + def __juju_decoder__(value: str) -> str: + """Pass Juju's string through unmodified to be decoded by individual field validators.""" + return value + + @staticmethod + def __juju_encoder__(value: str | None) -> str: + """Convert `None` to "", erasing the value; Ops will error on a non-string.""" + return "" if value is None else value + + +class ProviderAppData(_BareStringDatabag): + """The provider's application databag.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) + endpoints: str = Field( description="A comma-separated list of Kafka Connect REST endpoint(s), including the protocol (either `http` or `https`)", examples=["http://10.1.1.100:8083,http://10.1.1.101:8083,http://10.1.1.102:8083"], @@ -32,7 +47,11 @@ class ConnectProviderData(BaseModel): ) -class ConnectRequirerData(BaseModel): +class RequirerAppData(_BareStringDatabag): + """The requirer's application databag.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) + plugin_url: str = Field( description=f'URL at which the plugins required by this client are served as a single Tarball. If not required, the requirer should place the sentinel value "{PLUGIN_URL_NOT_REQUIRED}"', alias="plugin-url", @@ -41,19 +60,24 @@ class ConnectRequirerData(BaseModel): ) requested_secrets: list[str] = Field( alias="requested-secrets", - description="Any provider field which should be transfered as Juju Secret", + description="Any provider field which should be transfered as Juju Secret. A JSON array on the wire.", examples=[["username", "password", "tls-ca"]], title="Requested secrets", ) + @field_validator("requested_secrets", mode="before") + @classmethod + def _load_json(cls, value: Any) -> Any: + if not isinstance(value, str): + return value # __init__ argument was already deserialized. + return json.loads(value) -class ProviderSchema(DataBagSchema): - """The schema for the provider side of the `connect_client` interface.""" - - app: ConnectProviderData - + @field_serializer("requested_secrets") + def _dump_json(self, value: object) -> str | None: + if value is None: + return None + return json.dumps(value) -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of the `connect_client` interface.""" - app: ConnectRequirerData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/kafka_client/interface/v0/schema.py b/interfaces/kafka_client/interface/v0/schema.py index 44220c5c7..f887fcfc3 100644 --- a/interfaces/kafka_client/interface/v0/schema.py +++ b/interfaces/kafka_client/interface/v0/schema.py @@ -1,14 +1,8 @@ -"""This file defines the schemas for the provider and requirer sides of the kafka_client interface. - -It must expose two interfaces.schema_base.DataBagSchema subclasses called: -- ProviderSchema -- RequirerSchema -""" +"""This file defines the schemas for the provider and requirer sides of the kafka_client interface.""" from enum import Enum -from interface_tester.schema_base import DataBagSchema -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator class ExtraUserRole(str, Enum): @@ -17,29 +11,45 @@ class ExtraUserRole(str, Enum): producer = "producer" -class KafkaProviderData(BaseModel): +class _BareStringDatabag(BaseModel): + """Base class for databag models that don't strictly JSON encode all entries.""" + + @staticmethod + def __juju_decoder__(value: str) -> str: + """Pass Juju's string through unmodified to be decoded by individual field validators.""" + return value + + @staticmethod + def __juju_encoder__(value: str | None) -> str: + """Convert `None` to "", erasing the value; Ops will error on a non-string.""" + return "" if value is None else value + + +class ProviderAppData(_BareStringDatabag): """The databag for the provider side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + topic: str = Field( - description="The topic that has been made available to the relation user. Name defined in the Requirer's topic field", + description="The topic that has been made available to the relation user. Name defined in the Requirer's topic field. A bare string on the wire", examples=["topic-1", "appname-*"], title="Topic name", ) username: str = Field( - description="Username for connecting to the Kafka cluster", + description="Username for connecting to the Kafka cluster. A bare string on the wire, but usually delivered in a Juju secret instead, in which case the key is absent from the databag", examples=["relation-14"], title="Kafka SASL/SCRAM username", ) password: str = Field( - description="Password for connecting to the Kafka cluster", + description="Password for connecting to the Kafka cluster. A bare string on the wire, but usually delivered in a Juju secret instead, in which case the key is absent from the databag", examples=["alphanum-32byte-random"], title="Kafka SASL/SCRAM password", ) endpoints: str = Field( - description="A list of endpoints used to connect to the topic", + description="A list of endpoints used to connect to the topic. A bare string on the wire, comma separated if there is more than one endpoint", examples=["10.141.78.155:9092,10.141.78.62:9092,10.141.78.186:9092"], title="Kafka server endpoints", ) @@ -47,15 +57,15 @@ class KafkaProviderData(BaseModel): consumer_group_prefix: str | None = Field( None, alias="consumer-group-prefix", - description="A prefix for wildcard consumer-group IDs that have been granted permissions", + description="A prefix for wildcard consumer-group IDs that have been granted permissions. A bare string on the wire", examples=["relation-14-"], title="Kafka consumer group prefix", ) zookeeper_uris: str | None = Field( None, - alias="consumer-group-prefix", - description="A comma-seperated list of Zookeeper server URIs, and Kafka cluster zNode", + alias="zookeeper-uris", + description="A comma-seperated list of Zookeeper server URIs, and Kafka cluster zNode. A bare string on the wire", examples=["10.141.78.155:2181,10.141.78.62:2181,10.141.78.186:2181/kafka"], title="Zookeeper URIs", ) @@ -63,7 +73,7 @@ class KafkaProviderData(BaseModel): entity_name: str | None = Field( None, alias="entity-name", - description="Name for the requested custom entity", + description="Name for the requested custom entity. A bare string on the wire, but usually delivered in a Juju secret instead, in which case the key is absent from the databag", examples=["custom-role"], title="Entity name", ) @@ -71,17 +81,19 @@ class KafkaProviderData(BaseModel): entity_password: str | None = Field( None, alias="entity-password", - description="Password for the requested custom entity", + description="Password for the requested custom entity. A bare string on the wire, but usually delivered in a Juju secret instead, in which case the key is absent from the databag", examples=["alphanum-32byte-random"], title="Entity password", ) -class KafkaRequirerData(BaseModel): +class RequirerAppData(_BareStringDatabag): """The databag for the requirer side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + topic: str = Field( - description="The topic name access requested by the requirer", + description="The topic name access requested by the requirer. A bare string on the wire", examples=["topic-1", "appname-*"], title="Topic name", ) @@ -89,7 +101,7 @@ class KafkaRequirerData(BaseModel): consumer_group_prefix: str | None = Field( None, alias="consumer-group-prefix", - description="A prefix for wildcard consumer-group IDs that have been granted permissions", + description="A prefix for wildcard consumer-group IDs that have been granted permissions. A bare string on the wire", examples=["relation-14-"], title="Kafka consumer group prefix", ) @@ -97,7 +109,7 @@ class KafkaRequirerData(BaseModel): extra_user_roles: str | None = Field( None, alias="extra-user-roles", - description="Any extra user roles requested by the requirer", + description="Any extra user roles requested by the requirer. A bare string on the wire, comma separated if there is more than one role", examples=[ "consumer", "producer", @@ -113,7 +125,7 @@ class KafkaRequirerData(BaseModel): extra_group_roles: str | None = Field( None, alias="extra-group-roles", - description="Any extra group roles requested by the requirer", + description="Any extra group roles requested by the requirer. A bare string on the wire, comma separated if there is more than one role", examples=["charmed_read"], title="Extra group roles", ) @@ -121,7 +133,7 @@ class KafkaRequirerData(BaseModel): entity_type: str | None = Field( None, alias="entity-type", - description="Type of the requested entity (user / group)", + description="Type of the requested entity (user / group). A bare string on the wire", examples=["USER", "GROUP"], title="Entity type", ) @@ -129,7 +141,7 @@ class KafkaRequirerData(BaseModel): entity_permissions: str | None = Field( None, alias="entity-permissions", - description="List of permissions to assign to the custom entity, in JSON format", + description="List of permissions to assign to the custom entity, in JSON format. The library treats this as an opaque string, so it is written to the databag as-is rather than being re-encoded", examples=[ "[{\"resource_name\": \"messages\", \"resource_type\": \"TOPIC\", \"privileges\": [\"READ\"]}]" ], @@ -148,13 +160,5 @@ def capitalize(cls, value: str) -> str: return value -class ProviderSchema(DataBagSchema): - """The schema for the provider side of this interface.""" - - app: KafkaProviderData - - -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" - - app: KafkaRequirerData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/karapace_client/interface/v0/schema.py b/interfaces/karapace_client/interface/v0/schema.py index 3a100d3e0..74240c1aa 100644 --- a/interfaces/karapace_client/interface/v0/schema.py +++ b/interfaces/karapace_client/interface/v0/schema.py @@ -1,14 +1,8 @@ -"""This file defines the schemas for the provider and requirer sides of the karapace_client interface. - -It must expose two interfaces.schema_base.DataBagSchema subclasses called: -- ProviderSchema -- RequirerSchema -""" +"""This file defines the schemas for the provider and requirer sides of the karapace_client interface.""" from enum import Enum -from interface_tester.schema_base import DataBagSchema -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator class ExtraUserRole(str, Enum): @@ -16,9 +10,25 @@ class ExtraUserRole(str, Enum): user = "user" -class KarapaceProviderData(BaseModel): +class _BareStringDatabag(BaseModel): + """Base class for databag models that don't strictly JSON encode all entries.""" + + @staticmethod + def __juju_decoder__(value: str) -> str: + """Pass Juju's string through unmodified to be decoded by individual field validators.""" + return value + + @staticmethod + def __juju_encoder__(value: str | None) -> str: + """Convert `None` to "", erasing the value; Ops will error on a non-string.""" + return "" if value is None else value + + +class ProviderAppData(_BareStringDatabag): """The databag for the provider side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + subject: str = Field( description="The subject that has been made available to the relation user. Name defined in the Requirer's subject field", examples=["subject-1"], @@ -38,7 +48,7 @@ class KarapaceProviderData(BaseModel): ) endpoints: str = Field( - description="A list of endpoints used to connect to the subject", + description="A list of endpoints used to connect to the subject, comma separated on the wire", examples=["10.141.78.155:8082,10.141.78.62:8082,10.141.78.186:8082"], title="Karapace server endpoints", ) @@ -60,9 +70,11 @@ class KarapaceProviderData(BaseModel): ) -class KarapaceRequirerData(BaseModel): +class RequirerAppData(_BareStringDatabag): """The databag for the requirer side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + subject: str = Field( description="The subject name access requested by the requirer", examples=["subject-1"], @@ -96,7 +108,7 @@ class KarapaceRequirerData(BaseModel): entity_permissions: str | None = Field( None, alias="entity-permissions", - description="List of permissions to assign to the custom entity, in JSON format", + description="List of permissions to assign to the custom entity, in JSON format. Written to the databag as an opaque string", examples=[ "[{\"resource_name\": \"schemas\", \"resource_type\": \"SUBJECT\", \"privileges\": [\"READ\"]}]" ], @@ -105,7 +117,10 @@ class KarapaceRequirerData(BaseModel): @field_validator("extra_user_roles", mode="before") @classmethod - def extra_user_roles_validator(cls, value: str) -> str: + def extra_user_roles_validator(cls, value: str | None) -> str | None: + if value is None: + return value + try: _role = ExtraUserRole(value) except ValueError: @@ -114,13 +129,5 @@ def extra_user_roles_validator(cls, value: str) -> str: return value -class ProviderSchema(DataBagSchema): - """The schema for the provider side of this interface.""" - - app: KarapaceProviderData - - -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" - - app: KarapaceRequirerData +ProviderUnitData = None +RequirerUnitData = None