From 42a29dd65ad4f7fdd8a7c0d1aea0e19989c4cbbe Mon Sep 17 00:00:00 2001 From: James Garner Date: Thu, 3 Sep 2026 17:52:35 +1200 Subject: [PATCH] chore(interfaces): move the data 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. This also removes the interface test definitions for `azure_storage`. The interface tests are being retired; the tooling that ran them is removed separately. 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) --- .../interface/v0/schema.py | 67 +++++++++---- .../azure_storage/interface/v0/interface.yaml | 2 - .../azure_storage/interface/v0/schema.py | 85 +++++++++++++---- .../azure_storage/interface/v0/tests/.disable | 0 .../interface/v0/tests/test_provider.py | 44 --------- interfaces/azure_storage/ruff.toml | 7 -- interfaces/s3/interface/v1/schema.py | 93 +++++++++++++++---- 7 files changed, 188 insertions(+), 110 deletions(-) delete mode 100644 interfaces/azure_storage/interface/v0/tests/.disable delete mode 100644 interfaces/azure_storage/interface/v0/tests/test_provider.py diff --git a/interfaces/azure_service_principal/interface/v0/schema.py b/interfaces/azure_service_principal/interface/v0/schema.py index a280fece5..5a60e751b 100644 --- a/interfaces/azure_service_principal/interface/v0/schema.py +++ b/interfaces/azure_service_principal/interface/v0/schema.py @@ -1,58 +1,89 @@ -"""This file defines the schemas for the provider and requirer sides of the azure_service_principal interface. +"""This file defines the schemas for the provider and requirer sides of the azure_service_principal 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, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_serializer, field_validator -class AzureServicePrincipalProviderAppData(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): """Credentials for an Azure Service Principal.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + subscription_id: str = Field( + alias="subscription-id", description="The unique identifier for an Azure subscription.", examples=["12345678-1234-1234-1234-1234567890ab"], title="Subscription ID", ) tenant_id: str = Field( + alias="tenant-id", description="The unique identifier of the Azure Active Directory (Entra ID) tenant.", examples=["87654321-4321-4321-4321-ba0987654321"], title="Tenant ID", ) client_id: str = Field( + alias="client-id", description="The Application (client) ID for the service principal.", examples=["a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"], title="Client ID", ) client_secret: SecretStr = Field( + alias="client-secret", description="The client secret for the service principal, used for authentication.", examples=["aBcDeFgHiJkLmNoPqRsTuVwXyZ123456-~7890_"], title="Client Secret", ) + @field_serializer("client_secret") + def _reveal(self, value: SecretStr) -> str: + """Write the real secret to the databag rather than the masked repr.""" + return value.get_secret_value() + + +class RequirerAppData(_BareStringDatabag): + """The fields the requirer asks to be delivered as Juju secrets.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) -class AzureServicePrincipalRequirerAppData(BaseModel): requested_secrets: list[str] = Field( alias="requested-secrets", - description="Any provider field which should be transfered as a Juju secret", + description="Any provider field which should be transfered as a Juju secret. A JSON array on the wire.", examples=[["client-id", "client-secret"]], 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 this interface.""" - - app: AzureServicePrincipalProviderAppData - + @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 this interface.""" - app: AzureServicePrincipalRequirerAppData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/azure_storage/interface/v0/interface.yaml b/interfaces/azure_storage/interface/v0/interface.yaml index f076fca33..a873fe571 100644 --- a/interfaces/azure_storage/interface/v0/interface.yaml +++ b/interfaces/azure_storage/interface/v0/interface.yaml @@ -10,8 +10,6 @@ description: | providers: - name: azure-storage-integrator url: https://github.com/canonical/object-storage-integrators - test_setup: - charm_root: azure_storage requirers: - name: spark-integration-hub-k8s diff --git a/interfaces/azure_storage/interface/v0/schema.py b/interfaces/azure_storage/interface/v0/schema.py index e3c14a399..3c64629fb 100644 --- a/interfaces/azure_storage/interface/v0/schema.py +++ b/interfaces/azure_storage/interface/v0/schema.py @@ -1,14 +1,11 @@ -"""This file defines the schemas for the provider and requirer sides of the zookeeper_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 azure_storage interface.""" +import json +import pathlib from enum import Enum -from pathlib import Path +from typing import Any -from interface_tester.schema_base import DataBagSchema -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_serializer, field_validator class ConnectionProtocolEnum(str, Enum): @@ -18,7 +15,25 @@ class ConnectionProtocolEnum(str, Enum): adls_gen2_secure = "abfss" -class AzureStorageProviderAppData(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) + container: str = Field( description="The name of the Azure storage container provided by the provider.", examples=["mycontainer"], @@ -26,25 +41,29 @@ class AzureStorageProviderAppData(BaseModel): ) storage_account: str = Field( + alias="storage-account", description="The name of Azure storage account.", examples=["test-storage-account"], title="Storage account", ) connection_protocol: ConnectionProtocolEnum = Field( + alias="connection-protocol", description="The connection protocol to be used to connect to Azure Storage.", examples=["wasb", "wasbs", "abfs", "abfss"], default=ConnectionProtocolEnum.adls_gen2_secure, + strict=False, title="Connection protocol", ) secret_key: SecretStr = Field( + alias="secret-key", description="Secret key corresponding to the storage account for connecting to the object storage.", examples=["random-secret-key"], title="Secret key", ) - path: Path = Field( + path: str = Field( description="The path inside the container to store objects.", examples=["foo/bar"], title="Path", @@ -56,8 +75,29 @@ class AzureStorageProviderAppData(BaseModel): title="Endpoint URL", ) + @field_serializer("connection_protocol") + def _dump_enum(self, value: ConnectionProtocolEnum) -> str: + """Write the enum's wire string rather than the enum member.""" + return value.value + + @field_serializer("secret_key") + def _reveal(self, value: SecretStr) -> str: + """Write the real secret to the databag rather than the masked repr.""" + return value.get_secret_value() + + @field_validator("path") + @classmethod + def _check_path(cls, value: str) -> str: + """Check the value is a usable path, keeping any trailing slash.""" + pathlib.PurePosixPath(value) # raises if it isn't a usable path + return value + + +class RequirerAppData(_BareStringDatabag): + """The requirer's application databag.""" + + model_config = ConfigDict(strict=True, populate_by_name=True) -class AzureStorageRequirerAppData(BaseModel): container: str = Field( description="The name of the container that's requested by the requirer.", examples=["mycontainer"], @@ -66,19 +106,24 @@ class AzureStorageRequirerAppData(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", "uris"]], 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 this interface.""" - - app: AzureStorageProviderAppData - + @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 this interface.""" - app: AzureStorageRequirerAppData +ProviderUnitData = None +RequirerUnitData = None diff --git a/interfaces/azure_storage/interface/v0/tests/.disable b/interfaces/azure_storage/interface/v0/tests/.disable deleted file mode 100644 index e69de29bb..000000000 diff --git a/interfaces/azure_storage/interface/v0/tests/test_provider.py b/interfaces/azure_storage/interface/v0/tests/test_provider.py deleted file mode 100644 index c0e90e3ed..000000000 --- a/interfaces/azure_storage/interface/v0/tests/test_provider.py +++ /dev/null @@ -1,44 +0,0 @@ -from interface_tester import Tester -from scenario import Relation, State - - -def test_nothing_happens_if_remote_empty(): - # GIVEN that the remote end has not published anything on databag - t = Tester( - State( - leader=True, - relations=[ - Relation( - endpoint="azure-storage-credentials", - interface="azure_storage", - ) - ], - ) - ) - - # WHEN this charm receives a relation-joined event - state_out = t.run("azure-storage-credentials-relation-joined") - - # THEN no data is published to the (local) databags - t.assert_relation_data_empty() - - -def test_data_written_happy_path(): - # GIVEN that the remote end has requested a container in the right format - t = Tester( - State( - leader=True, - relations=[ - Relation( - endpoint="azure-storage-credentials", - interface="azure_storage", - remote_app_data={"container": "my-container"}, - ) - ], - ) - ) - # WHEN this charm receives a relation-changed event - state_out = t.run("azure-storage-credentials-relation-changed") - - # THEN the schema is satisfied (this charm published all required fields) - t.assert_schema_valid() diff --git a/interfaces/azure_storage/ruff.toml b/interfaces/azure_storage/ruff.toml index efbd8c5cc..79b043b3f 100644 --- a/interfaces/azure_storage/ruff.toml +++ b/interfaces/azure_storage/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/s3/interface/v1/schema.py b/interfaces/s3/interface/v1/schema.py index 1825573ac..1b77e4347 100644 --- a/interfaces/s3/interface/v1/schema.py +++ b/interfaces/s3/interface/v1/schema.py @@ -1,9 +1,10 @@ """Schemas for v1 of the s3 interface.""" +import json from enum import Enum, IntEnum +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 S3URIStyleEnum(str, Enum): @@ -16,9 +17,25 @@ class S3APIVersion(IntEnum): v4 = 4 -class S3ProviderAppData(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): """Data expected on the provider side for the s3 v1 interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + bucket: str | None = Field( description="The bucket/container name delivered by the provider.", examples=["minio"], @@ -64,6 +81,7 @@ class S3ProviderAppData(BaseModel): alias="s3-uri-style", description="The S3 protocol specific bucket path lookup type.", examples=["path", "host"], + strict=False, title="S3 URI Style", ) @@ -76,20 +94,24 @@ class S3ProviderAppData(BaseModel): tls_ca_chain: list[str] | None = Field( alias="tls-ca-chain", - description="The complete CA chain, which can be used for HTTPS validation.", + description=( + "The complete CA chain, which can be used for HTTPS validation." + " A JSON array on the wire." + ), examples=[["base64-encoded-ca-chain=="]], title="TLS CA Chain", ) s3_api_version: S3APIVersion | None = Field( alias="s3-api-version", - description="S3 protocol specific API signature.", + description="S3 protocol specific API signature. A decimal string on the wire.", examples=[2, 4], + strict=False, title="S3 API signature", ) attributes: list[str] | None = Field( - description="The custom metadata (HTTP headers).", + description="The custom metadata (HTTP headers). Semicolon separated on the wire.", examples=[ [ "Cache-Control=max-age=90000,min-fresh=9000", @@ -99,10 +121,48 @@ class S3ProviderAppData(BaseModel): title="Custom metadata", ) - -class S3RequirerAppData(BaseModel): + @field_serializer("s3_uri_style") + def _dump_uri_style(self, value: S3URIStyleEnum | None) -> str | None: + """Write the enum's wire string rather than the enum member.""" + return None if value is None else value.value + + @field_serializer("s3_api_version") + def _dump_api_version(self, value: S3APIVersion | None) -> str | None: + """Write the signature as a decimal string; Ops will error on a non-string.""" + return None if value is None else str(value.value) + + @field_validator("tls_ca_chain", 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) + + @field_serializer("tls_ca_chain") + def _dump_json(self, value: object) -> str | None: + if value is None: + return None + return json.dumps(value) + + @field_validator("attributes", mode="before") + @classmethod + def _split_semicolon_separated(cls, value: str | list[str] | None) -> list[str] | None: + if not isinstance(value, str): + return value # __init__ argument was already deserialized. + return value.split(";") + + @field_serializer("attributes") + def _join_semicolon_separated(self, value: list[str] | None) -> str | None: + if value is None: + return None + return ";".join(value) + + +class RequirerAppData(_BareStringDatabag): """Data expected on the requirer side for the s3 v1 interface.""" + model_config = ConfigDict(strict=True, populate_by_name=True) + bucket: str | None = Field( description="The name of the bucket/container requested by the requirer.", examples=["minio"], @@ -124,19 +184,14 @@ class S3RequirerAppData(BaseModel): requested_secrets: str = Field( alias="requested-secrets", - description="Any provider field which should be transferred as a Juju Secret.", + description=( + "Any provider field which should be transferred as a Juju Secret." + " A JSON array on the wire, written to the databag as an opaque string." + ), examples=[["access-key", "secret-key"]], title="Requested secrets", ) -class ProviderSchema(DataBagSchema): - """The schema for the provider side of this interface.""" - - app: S3ProviderAppData - - -class RequirerSchema(DataBagSchema): - """The schema for the requirer side of this interface.""" - - app: S3RequirerAppData +ProviderUnitData = None +RequirerUnitData = None