diff --git a/interfaces/etcd_client/interface/v0/schema.py b/interfaces/etcd_client/interface/v0/schema.py index 15eaeed37..ed02cd526 100644 --- a/interfaces/etcd_client/interface/v0/schema.py +++ b/interfaces/etcd_client/interface/v0/schema.py @@ -1,12 +1,27 @@ # Copyright 2025 Canonical # See LICENSE file for licensing details. -from interface_tester.schema_base import DataBagSchema -from pydantic import Field +from pydantic import BaseModel, ConfigDict, Field -class ProviderSchema(DataBagSchema): - """The schema for the provider side of this interface.""" +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="Comma separated list of etcd endpoints", @@ -21,20 +36,24 @@ class ProviderSchema(DataBagSchema): ) secret_tls: str = Field( + alias="secret-tls", description="Secret URI containing the tls-ca", title="TLS Secret URI", examples=["secret://12312323112313123213"], ) secret_user: str = Field( + alias="secret-user", description="Secret URI containing the etcd user information", title="User Secret URI", examples=["secret://12312323112313123213"], ) -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" +class RequirerAppData(_BareStringDatabag): + """The requirer's application databag.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) prefix: str = Field( description="The prefix of the range of keys requested", @@ -43,19 +62,26 @@ class RequirerSchema(DataBagSchema): ) secret_mtls: str = Field( + alias="secret-mtls", description="Secret URI containing the client certificate", title="mTLS Secret URI", examples=["secret://12312323112313123213"], ) requested_secrets: str = Field( - description="The fields required to be a secret.", + alias="requested-secrets", + description="The fields required to be a secret. A JSON array on the wire.", title="Requested Secrets", examples='["username", "uris", "tls", "tls-ca"]', ) provided_secrets: str = Field( - description="The fields provided as secrets", + alias="provided-secrets", + description="The fields provided as secrets. A JSON array on the wire.", title="Provided Secrets", examples='["mtls-cert"]', ) + + +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/etcd_client/interface/v0/tests/.disable b/interfaces/etcd_client/interface/v0/tests/.disable deleted file mode 100644 index e69de29bb..000000000 diff --git a/interfaces/etcd_client/interface/v0/tests/test_provider.py b/interfaces/etcd_client/interface/v0/tests/test_provider.py deleted file mode 100644 index 38cff3034..000000000 --- a/interfaces/etcd_client/interface/v0/tests/test_provider.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2025 Canonical -# See LICENSE file for licensing details. - -from interface_tester import Tester -from scenario import Relation, Secret, State - - -def test_nothing_happens_if_remote_empty(): - # GIVEN that the remote end has not published any tables - t = Tester( - State( - leader=True, - relations=[ - Relation( - endpoint="etcd-client", # the name doesn't matter - interface="etcd_client", - ) - ], - ) - ) - # WHEN the database charm receives a relation-joined event - state_out = t.run("etcd-client-relation-joined") - # THEN no data is published to the (local) databags - t.assert_relation_data_empty() - - -def test_add_provider_content(): - # GIVEN that the remote end has requested tables in the right format - secret = Secret({"mtls-cert": "test_ca"}, owner="app") - t = Tester( - State( - leader=True, - relations=[ - Relation( - endpoint="etcd-client", # the name doesn't matter - interface="etcd_client", - remote_app_data={ - "prefix": "/my/keys", - "secret-mtls": secret.id, - "requested-secrets": ["username", "uris", "tls", "tls-ca"], - "provided-secrets": ["mtls-cert"], - }, - ) - ], - ) - ) - # WHEN the database charm receives a relation-changed event - state_out = t.run("etcd-client-relation-changed") - # THEN the schema is satisfied (the database charm published all required fields) - t.assert_schema_valid() diff --git a/interfaces/etcd_client/interface/v0/tests/test_requirer.py b/interfaces/etcd_client/interface/v0/tests/test_requirer.py deleted file mode 100644 index df82b2694..000000000 --- a/interfaces/etcd_client/interface/v0/tests/test_requirer.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2025 Canonical -# See LICENSE file for licensing details. - -from interface_tester import Tester -from scenario import Relation, State - - -def test_add_content_on_relation_created(): - # GIVEN that the remote end has not published any tables - t = Tester( - State( - leader=True, - relations=[ - Relation( - endpoint="etcd-client", - interface="etcd_client", - ) - ], - ) - ) - # WHEN the database charm receives a relation-joined event - state_out = t.run("etcd-client-relation-joined") - # THEN no data is published to the (local) databags - t.assert_schema_valid() diff --git a/interfaces/etcd_client/ruff.toml b/interfaces/etcd_client/ruff.toml index efbd8c5cc..79b043b3f 100644 --- a/interfaces/etcd_client/ruff.toml +++ b/interfaces/etcd_client/ruff.toml @@ -9,10 +9,3 @@ quote-style = "preserve" "D", # docs "E501", # line too long ] -"./interface/v*/tests/*.py" = [ - "CPY", # copyright - "D", # docs - "S", # security - "E501", # line too long - "F841", # assignment to unused variable -] diff --git a/interfaces/mongodb_client/interface/v0/schema.py b/interfaces/mongodb_client/interface/v0/schema.py index d1e08f6d5..2b85d0899 100644 --- a/interfaces/mongodb_client/interface/v0/schema.py +++ b/interfaces/mongodb_client/interface/v0/schema.py @@ -1,17 +1,30 @@ -"""This file defines the schemas for the provider and requirer sides of the mongodb_client interface. +"""This file defines the schemas for the provider and requirer sides of the mongodb_client interface.""" -It must expose 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 -class MongoDBProviderData(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) + database: str = Field( description="The database name delivered by the provider. Might not be the same as requested by the requirer", examples=["myapp"], @@ -74,9 +87,11 @@ class MongoDBProviderData(BaseModel): ) -class MongoDBRequirerData(BaseModel): +class RequirerAppData(_BareStringDatabag): """The databag for the requirer side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + database: str = Field( description="The database name requested by the requirer", examples=["myapp"], @@ -85,7 +100,7 @@ class MongoDBRequirerData(BaseModel): requested_secrets: list[str] = Field( alias="requested-secrets", - description="Any provider field which should be transferred as Juju Secret", + description="Any provider field which should be transferred as Juju Secret. A JSON array on the wire.", examples=[["username", "password"]], title="Requested secrets", ) @@ -124,14 +139,17 @@ class MongoDBRequirerData(BaseModel): title="Entity permissions", ) + @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 this interface.""" - - app: MongoDBProviderData - + @field_serializer("requested_secrets") + def _dump_json(self, value: object) -> str: + return json.dumps(value) -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" - app: MongoDBRequirerData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/opensearch_client/interface/v0/schema.py b/interfaces/opensearch_client/interface/v0/schema.py index 3dcfcaeaa..4b31a5d30 100644 --- a/interfaces/opensearch_client/interface/v0/schema.py +++ b/interfaces/opensearch_client/interface/v0/schema.py @@ -1,17 +1,30 @@ -"""This file defines the schemas for the provider and requirer sides of the opensearch_client interface. +"""This file defines the schemas for the provider and requirer sides of the opensearch_client interface.""" -It must expose 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 -class OpenSearchProviderData(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) + index: str = Field( description="The index that has been made available to the relation user. Name defined in the Requirer's index field", examples=["myindex"], @@ -60,9 +73,11 @@ class OpenSearchProviderData(BaseModel): ) -class OpenSearchRequirerData(BaseModel): +class RequirerAppData(_BareStringDatabag): """The databag for the requirer side of this interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + index: str = Field( description="The index name requested by the requirer", examples=["myindex"], @@ -71,7 +86,7 @@ class OpenSearchRequirerData(BaseModel): requested_secrets: list[str] = Field( alias="requested-secrets", - description="Any provider field which should be transferred as Juju Secret", + description="Any provider field which should be transferred as Juju Secret. A JSON array on the wire.", examples=[["username", "password"]], title="Requested secrets", ) @@ -110,14 +125,17 @@ class OpenSearchRequirerData(BaseModel): title="Entity permissions", ) + @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 this interface.""" - - app: OpenSearchProviderData - + @field_serializer("requested_secrets") + def _dump_json(self, value: object) -> str: + return json.dumps(value) -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" - app: OpenSearchRequirerData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/valkey_client/interface/v1/schema.py b/interfaces/valkey_client/interface/v1/schema.py index 420dbfef6..097ce29cf 100644 --- a/interfaces/valkey_client/interface/v1/schema.py +++ b/interfaces/valkey_client/interface/v1/schema.py @@ -1,12 +1,27 @@ # Copyright 2026 Canonical # See LICENSE file for licensing details. -from interface_tester.schema_base import DataBagSchema -from pydantic import Field +from pydantic import BaseModel, ConfigDict, Field -class ProviderSchema(DataBagSchema): - """The schema for the provider side of this interface.""" +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="Comma separated list of Valkey read/write endpoints", @@ -15,6 +30,7 @@ class ProviderSchema(DataBagSchema): ) read_only_endpoints: str = Field( + alias="read-only-endpoints", description="Comma separated list of Valkey read-only endpoints", title="Valkey read-only Endpoints", examples=["valkey-0.valkey-endpoints:6379,valkey-2.valkey-endpoints:6379"], @@ -41,20 +57,24 @@ class ProviderSchema(DataBagSchema): ) secret_tls: str = Field( + alias="secret-tls", description="Secret URI containing the tls-ca", title="TLS Secret URI", examples=["secret://12312323112313123213"], ) secret_user: str = Field( + alias="secret-user", description="Secret URI containing the Valkey user information", title="User Secret URI", examples=["secret://12312323112313123213"], ) -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" +class RequirerAppData(_BareStringDatabag): + """The requirer's application databag.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) resource: str = Field( description="The prefix of the range of keys requested", @@ -63,13 +83,19 @@ class RequirerSchema(DataBagSchema): ) secret_mtls: str = Field( + alias="secret-mtls", description="Secret URI containing the client certificate", title="mTLS Secret URI", examples=["secret://12312323112313123213"], ) requested_secrets: str = Field( - description="The fields required to be a secret.", + alias="requested-secrets", + description="The fields required to be a secret. A JSON array on the wire.", title="Requested Secrets", examples='["username", "password", "tls", "tls-ca"]', ) + + +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/valkey_client/ruff.toml b/interfaces/valkey_client/ruff.toml index efbd8c5cc..79b043b3f 100644 --- a/interfaces/valkey_client/ruff.toml +++ b/interfaces/valkey_client/ruff.toml @@ -9,10 +9,3 @@ quote-style = "preserve" "D", # docs "E501", # line too long ] -"./interface/v*/tests/*.py" = [ - "CPY", # copyright - "D", # docs - "S", # security - "E501", # line too long - "F841", # assignment to unused variable -]