diff --git a/charmcraft.yaml b/charmcraft.yaml index fcf9291..0284f5d 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -26,7 +26,7 @@ parts: - astral-uv charm-requirements: ["requirements.txt"] charm-binary-python-packages: - - jsonschema ~= 4.23.0 + - rpds_py ~= 0.22.3 override-build: | just requirements craftctl default diff --git a/justfile b/justfile index 8c2d655..633fb7f 100644 --- a/justfile +++ b/justfile @@ -13,33 +13,37 @@ export PYTHONBREAKPOINT := "pdb.set_trace" uv_run := "uv run --frozen --extra dev" +# Regenerate uv.lock +lock: + uv lock --no-cache + # Upgrade uv.lock with the latest deps upgrade: uv lock --upgrade --no-cache # Generate requirements.txt from pyproject.toml -requirements: +requirements: lock uv export --frozen --no-hashes --format=requirements-txt -o requirements.txt # Apply coding style standards to code -fmt: +fmt: lock echo {{PYTHONPATH}} {{uv_run}} ruff format {{all}} {{uv_run}} ruff check --fix {{all}} # Check code against coding style standards -lint: +lint: lock {{uv_run}} codespell {{lib}} {{uv_run}} codespell {{project_dir}} {{uv_run}} ruff check {{all}} {{uv_run}} ruff format --check --diff {{all}} # Run static type checks -static *args: +static *args: lock {{uv_run}} pyright {{args}} # Run unit tests -unit *args: +unit *args: lock {{uv_run}} coverage run \ --source={{src}} \ --source={{lib}} \ @@ -52,7 +56,7 @@ unit *args: {{uv_run}} coverage report # Run integration tests -integration *args: +integration *args: lock {{uv_run}} pytest \ -v \ -s \ diff --git a/lib/charms/storage_client/v0/fs_interfaces.py b/lib/charms/filesystem_client/v0/interfaces.py similarity index 72% rename from lib/charms/storage_client/v0/fs_interfaces.py rename to lib/charms/filesystem_client/v0/interfaces.py index 835e8e4..4927980 100644 --- a/lib/charms/storage_client/v0/fs_interfaces.py +++ b/lib/charms/filesystem_client/v0/interfaces.py @@ -17,7 +17,7 @@ This library contains the FsProvides and FsRequires classes for managing an integration between a filesystem server operator and a filesystem client operator. -## ShareInfo (filesystem mount data) +## FsInfo (filesystem mount information) This abstract class defines the methods that a filesystem type must expose for providers and consumers. Any subclass of this class will be compatible with the other methods exposed @@ -26,13 +26,13 @@ ## FsRequires (filesystem client) -This class provides a uniform interface for charms that need to mount or unmount filesystem shares, +This class provides a uniform interface for charms that need to mount or unmount filesystems, and convenience methods for consuming data sent by a filesystem server charm. ### Defined events -- `mount_share`: Event emitted when the filesystem is ready to be mounted. -- `umount_share`: Event emitted when the filesystem needs to be unmounted. +- `mount_fs`: Event emitted when the filesystem is ready to be mounted. +- `umount_fs`: Event emitted when the filesystem needs to be unmounted. ### Example @@ -40,46 +40,46 @@ import ops from charms.storage_libs.v0.fs_interfaces import ( FsRequires, - MountShareEvent, + MountFsEvent, ) class StorageClientCharm(ops.CharmBase): - # Application charm that needs to mount filesystem shares. + # Application charm that needs to mount filesystems. def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # Charm events defined in the FsRequires class. - self.fs_share = FsRequires(self, "fs-share") + self._fs = FsRequires(self, "fs-share") self.framework.observe( - self.fs_share.on.mount_share, - self._on_mount_share, + self._fs.on.mount_fs, + self._on_mount_fs, ) - def _on_server_connected(self, event: MountShareEvent) -> None: + def _on_mount_fs(self, event: MountShareEvent) -> None: # Handle when new filesystem server is connected. - share_info = event.share_info + fs_info = event.fs_info - self.mount("/mnt", share_info) + self.mount("/mnt", fs_info) - self.unit.status = ops.ActiveStatus("Mounted share at `/mnt`.") + self.unit.status = ops.ActiveStatus("Mounted filesystem at `/mnt`.") ``` ## FsProvides (filesystem server) -This library provides a uniform interface for charms that expose filesystem shares. +This library provides a uniform interface for charms that expose filesystems. -> __Note:__ It is the responsibility of the filesystem Provider charm to provide -> the implementation for creating a new filesystem share. FsProvides just provides +> __Note:__ It is the responsibility of the provider charm to have +> the implementation for creating a new filesystem share. FsProvides just exposes > the interface for the integration. ### Example ```python import ops -from charms.storage_client.v0.fs_interfaces import ( +from charms.filesystem_client.v0.fs_interfaces import ( FsProvides, NfsInfo, ) @@ -87,12 +87,12 @@ def _on_server_connected(self, event: MountShareEvent) -> None: class StorageServerCharm(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) - self._fs_share = FsProvides(self, "fs-share", "server-peers") + self._fs = FsProvides(self, "fs-share", "server-peers") framework.observe(self.on.start, self._on_start) def _on_start(self, event: ops.StartEvent): # Handle start event. - self._fs_share.set_fs_info(NfsInfo("192.168.1.254", 65535, "/srv")) + self._fs.set_fs_info(NfsInfo("192.168.1.254", 65535, "/srv")) self.unit.status = ops.ActiveStatus() ``` """ @@ -101,7 +101,7 @@ def _on_start(self, event: ops.StartEvent): from abc import ABC, abstractmethod from dataclasses import dataclass from ipaddress import AddressValueError, IPv6Address -from typing import Dict, List, Optional, TypeVar +from typing import List, Optional, TypeVar from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunsplit import ops @@ -117,13 +117,14 @@ def _on_start(self, event: ops.StartEvent): from ops.model import Model, Relation __all__ = [ - "FsInterfacesError", + "InterfacesError", "ParseError", - "Share", - "ShareInfo", + "FsInfo", "NfsInfo", - "MountShareEvent", - "UmountShareEvent", + "CephfsInfo", + "Endpoint", + "MountFsEvent", + "UmountFsEvent", "FsRequires", "FsProvides", ] @@ -140,13 +141,15 @@ def _on_start(self, event: ops.StartEvent): _logger = logging.getLogger(__name__) -class FsInterfacesError(Exception): - """Exception raised when a filesystem operation failed.""" +class InterfacesError(Exception): + """Exception raised when an operation failed.""" -class ParseError(FsInterfacesError): + +class ParseError(InterfacesError): """Exception raised when a parse operation from an URI failed.""" + # Design-wise, this class represents the grammar that relations use to # share data between providers and requirers: # @@ -160,7 +163,7 @@ class ParseError(FsInterfacesError): # URI = scheme "://" authority path-absolute ["?" options] # # Unspecified grammar rules are given by [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#appendix-A). -# +# # This essentially leaves 5 components that the library can use to share data: # - scheme: representing the type of filesystem. # - hosts: representing the list of hosts where the filesystem lives. For NFS it should be a single element, @@ -184,6 +187,7 @@ class ParseError(FsInterfacesError): @dataclass(init=False, frozen=True) class _UriData: """Raw data from the endpoint URI of a relation.""" + scheme: str """Scheme used to identify a filesystem. @@ -202,96 +206,110 @@ class _UriData: options: dict[str, str] """Additional options that could be required to mount the filesystem.""" - def __init__(self, scheme: str, hosts: [str], user: str = "", path: str = "", options: dict[str, str] = {}): + def __init__( + self, + scheme: str, + hosts: [str], + user: str = "", + path: str = "/", + options: dict[str, str] = {}, + ): if not scheme: - raise FsInterfacesError("scheme cannot be empty") + raise InterfacesError("scheme cannot be empty") if len(hosts) == 0: - raise FsInterfacesError("list of hosts cannot be empty") + raise InterfacesError("list of hosts cannot be empty") # Strictly convert to the required types to avoid passing through weird data. - self.scheme = str(scheme) - self.hosts = [str(host) for host in hosts] - self.user = str(user) if user else "" - self.path = str(path) if path else "/" - self.options = { str(k): str(v) for k,v in options.items() } if options else {} + object.__setattr__(self, "scheme", str(scheme)) + object.__setattr__(self, "hosts", [str(host) for host in hosts]) + object.__setattr__(self, "user", str(user) if user else "") + object.__setattr__(self, "path", str(path) if path else "/") + object.__setattr__( + self, "options", {str(k): str(v) for k, v in options.items()} if options else {} + ) @classmethod - def from_uri(uri: str) -> _UriData: + def from_uri(cls, uri: str) -> "_UriData": """Convert an URI string into a `_UriData`.""" + _logger.debug(f"_UriData.from_uri: parsing `{uri}`") - _logger.debug(f"parsing `{uri}`") - - uri = urlparse(uri, allow_fragments=False) - scheme = str(uri.scheme) - user = unquote(uri.username) - hostname = unquote(uri.hostname) + result = urlparse(uri, allow_fragments=False) + scheme = str(result.scheme or "") + user = unquote(result.username or "") + hostname = unquote(result.hostname or "") if not hostname or hostname[0] != "(" or hostname[-1] != ")": - _logger.debug(f"parsing failed for hostname `{hostname}`") - raise ParseError("invalid list of hosts for endpoint") + raise ParseError(f"invalid list of hosts for endpoint `{uri}`") - hosts = hostname.split(",") - path = uri.path + hosts = hostname[1:-1].split(",") + path = unquote(result.path or "") try: - options = parse_qs(uri.query, strict_parsing=True) + options = { + key: ",".join(values) + for key, values in parse_qs(result.query, strict_parsing=True).items() + } except ValueError: - _logger.debug(f"parsing failed for query `{uri.query}`") - raise ParseError("invalid options for endpoint info") + raise ParseError(f"invalid options for endpoint `{uri}`") try: return _UriData(scheme=scheme, user=user, hosts=hosts, path=path, options=options) - except FsInterfacesError as e: + except InterfacesError as e: raise ParseError(*e.args) def __str__(self) -> str: user = quote(self.user) hostname = quote(",".join(self.hosts)) - netloc = f"{user}@" if user else "" + f"({self.hostname})" + path = quote(self.path) + netloc = f"{user}@({hostname})" if user else f"({hostname})" query = urlencode(self.options) - return urlunsplit((self.scheme, netloc, self.path, query, None)) + return urlunsplit((self.scheme, netloc, path, query, None)) def _hostinfo(host: str) -> tuple[str, Optional[int]]: """Parse a host string into the hostname and the port.""" + _logger.debug(f"_hostinfo: parsing `{host}`") if len(host) == 0: raise ParseError("invalid empty host") pos = 0 if host[pos] == "[": # IPv6 - pos = host.find(']', pos) + pos = host.find("]", pos) if pos == -1: raise ParseError("unclosed bracket for host") hostname = host[1:pos] pos = pos + 1 else: # IPv4 or DN - pos = host.find(':', pos) + pos = host.find(":", pos) if pos == -1: pos = len(host) hostname = host[:pos] - + if pos == len(host): return hostname, None # more characters after the hostname <==> port - - if hostname[pos] != ":": + + if host[pos] != ":": raise ParseError("expected `:` after IPv6 address") try: - port = int(host[pos + 1:]) + port = int(host[pos + 1 :]) except ValueError: raise ParseError("expected int after `:` in host") + return hostname, port + -T = TypeVar("T", bound="ShareInfo") +T = TypeVar("T", bound="FsInfo") class FsInfo(ABC): """Information to mount a filesystem. This is an abstract class that exposes a set of required methods. All filesystems that - can be handled by this library must derive this abstract class. + can be handled by this library must derive this abstract class. """ + @classmethod @abstractmethod def from_uri(cls: type[T], uri: str, model: Model) -> T: @@ -301,20 +319,19 @@ def from_uri(cls: type[T], uri: str, model: Model) -> T: def to_uri(self, model: Model) -> str: """Convert this `FsInfo` object into an URI string.""" - @abstractmethod def grant(self, model: Model, relation: ops.Relation): """Grant permissions for a certain relation to any secrets that this `FsInfo` has. This is an optional method because not all filesystems will require secrets to be mounted on the client. """ - return @classmethod @abstractmethod def fs_type(cls) -> str: """Get the string identifier of this filesystem type.""" + @dataclass(frozen=True) class NfsInfo(FsInfo): """Information required to mount an NFS share.""" @@ -330,13 +347,16 @@ class NfsInfo(FsInfo): @classmethod def from_uri(cls, uri: str, _model: Model) -> "NfsInfo": + """See :py:meth:`FsInfo.from_uri` for documentation on this method.""" + _logger.debug(f"NfsInfo.from_uri: parsing `{uri}`") + info = _UriData.from_uri(uri) if info.scheme != cls.fs_type(): raise ParseError( "could not parse `EndpointInfo` with incompatible scheme into `NfsInfo`" ) - + path = info.path if info.user: @@ -352,6 +372,7 @@ def from_uri(cls, uri: str, _model: Model) -> "NfsInfo": return NfsInfo(hostname=hostname, port=port, path=path) def to_uri(self, _model: Model) -> str: + """See :py:meth:`FsInfo.to_uri` for documentation on this method.""" try: IPv6Address(self.hostname) host = f"[{self.hostname}]" @@ -364,12 +385,14 @@ def to_uri(self, _model: Model) -> str: @classmethod def fs_type(cls) -> str: + """See :py:meth:`FsInfo.fs_type` for documentation on this method.""" return "nfs" + @dataclass(frozen=True) class CephfsInfo(FsInfo): """Information required to mount a CephFS share.""" - + fsid: str """Cluster identifier.""" @@ -387,10 +410,12 @@ class CephfsInfo(FsInfo): key: str """Cephx key for the authorized user.""" - + @classmethod def from_uri(cls, uri: str, model: Model) -> "CephfsInfo": - info = _parse_uri(uri) + """See :py:meth:`FsInfo.from_uri` for documentation on this method.""" + _logger.debug(f"CephfsInfo.from_uri: parsing `{uri}`") + info = _UriData.from_uri(uri) if info.scheme != cls.fs_type(): raise ParseError( @@ -398,44 +423,41 @@ def from_uri(cls, uri: str, model: Model) -> "CephfsInfo": ) path = info.path - + if not (user := info.user): - raise ParseError( - "missing user in uri for `CephfsInfo" - ) - + raise ParseError("missing user in uri for `CephfsInfo") + if not (name := info.options.get("name")): - raise ParseError( - "missing name in uri for `CephfsInfo`" - ) - + raise ParseError("missing name in uri for `CephfsInfo`") + if not (fsid := info.options.get("fsid")): - raise ParseError( - "missing fsid in uri for `CephfsInfo`" - ) - + raise ParseError("missing fsid in uri for `CephfsInfo`") + monitor_hosts = info.hosts if not (auth := info.options.get("auth")): - raise ParseError( - "missing auth info in uri for `CephsInfo`" - ) + raise ParseError("missing auth info in uri for `CephsInfo`") try: kind, data = auth.split(":", 1) except ValueError: raise ParseError("Could not get the kind of auth info") - + if kind == "secret": key = model.get_secret(id=auth).get_content(refresh=True)["key"] elif kind == "plain": + # Enables being able to pass data from reactive charms (such as `ceph-fs`), since + # they don't support secrets. key = data else: - raise ParseError("Invalid kind for auth info") - - return CephfsInfo(fsid=fsid, name=name, path=path, monitor_hosts=monitor_hosts, user=user, key=key) + raise ParseError(f"Invalid kind `{kind}` for auth info") + + return CephfsInfo( + fsid=fsid, name=name, path=path, monitor_hosts=monitor_hosts, user=user, key=key + ) def to_uri(self, model: Model) -> str: + """See :py:meth:`FsInfo.to_uri` for documentation on this method.""" secret = self._get_or_create_auth_secret(model) options = { @@ -445,49 +467,59 @@ def to_uri(self, model: Model) -> str: "auth-rev": str(secret.get_info().revision), } - return str(_UriData(scheme=self.fs_type(), hosts=self.monitor_hosts, path=self.path, user=self.user, options=options)) - - @abstractmethod + return str( + _UriData( + scheme=self.fs_type(), + hosts=self.monitor_hosts, + path=self.path, + user=self.user, + options=options, + ) + ) + def grant(self, model: Model, relation: Relation): - self._get_or_create_auth_secret(model) + """See :py:meth:`FsInfo.grant` for documentation on this method.""" + secret = self._get_or_create_auth_secret(model) secret.grant(relation) @classmethod def fs_type(cls) -> str: - return "ceph" - + """See :py:meth:`FsInfo.fs_type` for documentation on this method.""" + return "cephfs" + def _get_or_create_auth_secret(self, model: Model) -> ops.Secret: try: secret = model.get_secret(label="auth") secret.set_content({"key": self.key}) except ops.SecretNotFoundError: secret = model.app.add_secret( - self.key, + {"key": self.key}, label="auth", - description="Cephx key to authenticate against the CephFS share" + description="Cephx key to authenticate against the CephFS share", ) return secret + @dataclass class Endpoint: """Endpoint data exposed by a filesystem server.""" - + fs_info: FsInfo """Filesystem information required to mount this endpoint.""" uri: str """Raw URI exposed by this endpoint.""" -def _uri_to_share_info(uri: str, model: Model) -> FsInfo: - match uri.split("://", maxsplit=1)[0]: - case "nfs": - return NfsInfo.from_uri(uri, model) - case "ceph": - return CephfsInfo.from_uri(uri, model) - case _: - raise FsInterfacesError("unsupported share type") +def _uri_to_fs_info(uri: str, model: Model) -> FsInfo: + scheme = uri.split("://", maxsplit=1)[0] + if scheme == NfsInfo.fs_type(): + return NfsInfo.from_uri(uri, model) + elif scheme == CephfsInfo.fs_type(): + return CephfsInfo.from_uri(uri, model) + else: + raise InterfacesError(f"unsupported filesystem type `{scheme}`") class _MountEvent(RelationEvent): @@ -498,27 +530,26 @@ def endpoint(self) -> Optional[Endpoint]: """Get endpoint info.""" if not (uri := self.relation.data[self.relation.app].get("endpoint")): return - return Endpoint(_uri_to_share_info(uri, self.framework.model), uri) + return Endpoint(_uri_to_fs_info(uri, self.framework.model), uri) -class MountShareEvent(_MountEvent): - """Emit when FS share is ready to be mounted.""" +class MountFsEvent(_MountEvent): + """Emit when a filesystem is ready to be mounted.""" -class UmountShareEvent(_MountEvent): - """Emit when FS share needs to be unmounted.""" +class UmountFsEvent(_MountEvent): + """Emit when a filesystem needs to be unmounted.""" class _FsRequiresEvents(CharmEvents): """Events that FS servers can emit.""" - mount_share = EventSource(MountShareEvent) - umount_share = EventSource(UmountShareEvent) - + mount_fs = EventSource(MountFsEvent) + umount_fs = EventSource(UmountFsEvent) class _BaseInterface(Object): - """Base methods required for FS share integration interfaces.""" + """Base methods required for filesystem integration interfaces.""" def __init__(self, charm: CharmBase, relation_name) -> None: super().__init__(charm, relation_name) @@ -533,10 +564,11 @@ def relations(self) -> List[Relation]: result = [] for relation in self.charm.model.relations[self.relation_name]: try: + # Exclude relations that don't have any data yet _ = repr(relation.data) result.append(relation) except RuntimeError: - pass + continue return result @@ -555,12 +587,12 @@ def __init__(self, charm: CharmBase, relation_name: str) -> None: def _on_relation_changed(self, event: RelationChangedEvent) -> None: """Handle when the databag between client and server has been updated.""" _logger.debug("Emitting `MountShare` event from `RelationChanged` hook") - self.on.mount_share.emit(event.relation, app=event.app, unit=event.unit) + self.on.mount_fs.emit(event.relation, app=event.app, unit=event.unit) def _on_relation_departed(self, event: RelationDepartedEvent) -> None: """Handle when server departs integration.""" _logger.debug("Emitting `UmountShare` event from `RelationDeparted` hook") - self.on.umount_share.emit(event.relation, app=event.app, unit=event.unit) + self.on.umount_fs.emit(event.relation, app=event.app, unit=event.unit) @property def endpoints(self) -> List[Endpoint]: @@ -568,8 +600,8 @@ def endpoints(self) -> List[Endpoint]: result = [] for relation in self.relations: if not (uri := relation.data[relation.app].get("endpoint")): - pass - result.append(Endpoint(fs_info=_uri_to_share_info(uri, self.model), uri=uri)) + continue + result.append(Endpoint(fs_info=_uri_to_fs_info(uri, self.model), uri=uri)) return result @@ -585,7 +617,7 @@ def set_fs_info(self, fs_info: FsInfo) -> None: """Set information to mount a filesystem. Args: - share_info: Information required to mount the filesystem. + fs_info: Information required to mount the filesystem. Notes: Only the application leader unit can set the filesystem data. @@ -605,8 +637,8 @@ def _update_relation(self, event: RelationJoinedEvent) -> None: if not self.unit.is_leader() or not (endpoint := self._endpoint): return - share_info = _uri_to_share_info(endpoint, self.model) - share_info.grant(self.model, event.relation) + fs_info = _uri_to_fs_info(endpoint, self.model) + fs_info.grant(self.model, event.relation) event.relation.data[self.app]["endpoint"] = endpoint @@ -634,7 +666,7 @@ def _get_state(self, key: str) -> Optional[str]: def _set_state(self, key: str, data: str) -> None: """Insert a value into the global state.""" if not self._peers: - raise FsInterfacesError( + raise InterfacesError( "Peer relation can only be accessed after the relation is established" ) diff --git a/pyproject.toml b/pyproject.toml index 820167a..19b0bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ version = "0.0" requires-python = "==3.12.*" dependencies = [ "ops ~= 2.8", - "jsonschema ~= 4.23.0" + "jsonschema ~= 4.23.0", + "rpds_py ~= 0.22.3" ] [project.optional-dependencies] @@ -16,6 +17,7 @@ dev = [ # Unit "pytest", + "pytest-order", "coverage[toml]", "ops[testing]", diff --git a/src/charm.py b/src/charm.py index e15c61e..d110d15 100755 --- a/src/charm.py +++ b/src/charm.py @@ -8,14 +8,14 @@ import logging from collections import Counter from contextlib import contextmanager -from typing import Any, Optional, Generator +from typing import Any, Generator, Optional import charms.operator_libs_linux.v0.apt as apt import ops -from charms.storage_client.v0.fs_interfaces import FsRequires, Endpoint +from charms.filesystem_client.v0.interfaces import FsRequires from jsonschema import ValidationError, validate -from utils.manager import MountManager +from utils.manager import MountsManager logger = logging.getLogger(__name__) @@ -37,31 +37,33 @@ PEER_NAME = "storage-peers" -class StorageClientCharm(ops.CharmBase): + +class FilesystemClientCharm(ops.CharmBase): """Charm the application.""" def __init__(self, framework: ops.Framework): super().__init__(framework) self._fs_share = FsRequires(self, "fs-share") - self._mount_manager = MountManager() + self._mounts_manager = MountsManager() framework.observe(self.on.upgrade_charm, self._handle_event) framework.observe(self.on.update_status, self._handle_event) framework.observe(self.on.config_changed, self._handle_event) - framework.observe(self._fs_share.on.mount_share, self._handle_event) - framework.observe(self._fs_share.on.umount_share, self._handle_event) + framework.observe(self._fs_share.on.mount_fs, self._handle_event) + framework.observe(self._fs_share.on.umount_fs, self._handle_event) - def _handle_event(self, event: ops.EventBase) -> None: + def _handle_event(self, event: ops.EventBase) -> None: # noqa: C901 self.unit.status = ops.MaintenanceStatus("Updating status.") - if not self._mount_manager.installed: + if not self._mounts_manager.installed: self.unit.status = ops.MaintenanceStatus("Installing required packages.") - self._mount_manager.ensure(apt.PackageState.Present) + self._mounts_manager.ensure(apt.PackageState.Present) try: - config: dict[str, dict[str, str | bool]] = json.loads(self.config.get("mountinfo")) + config = json.loads(self.config.get("mountinfo")) validate(config, CONFIG_SCHEMA) - for (fs, opts) in config.items(): + config: dict[str, dict[str, str | bool]] = config + for fs, opts in config.items(): for opt in ["noexec", "nosuid", "nodev", "read-only"]: opts[opt] = opts.get(opt, False) except (json.JSONDecodeError, ValidationError) as e: @@ -72,9 +74,11 @@ def _handle_event(self, event: ops.EventBase) -> None: shares = self._fs_share.endpoints active_filesystems = set() - for fs_type, count in Counter([share.fs_info.scheme() for share in shares]).items(): + for fs_type, count in Counter([share.fs_info.fs_type() for share in shares]).items(): if count > 1: - self.app.status = ops.BlockedStatus(f"Too many relations for mount type `{fs_type}`.") + self.app.status = ops.BlockedStatus( + f"Too many relations for mount type `{fs_type}`." + ) return active_filesystems.add(fs_type) @@ -82,20 +86,22 @@ def _handle_event(self, event: ops.EventBase) -> None: # Cleanup and unmount all the mounts that are not available. for fs_type in list(mounts.keys()): if fs_type not in active_filesystems: - self._mount_manager.umount(mounts[fs_type]["mountpoint"]) + self._mounts_manager.umount(mounts[fs_type]["mountpoint"]) del mounts[fs_type] for share in shares: - fs_type = share.fs_info.scheme() + fs_type = share.fs_info.fs_type() if not (options := config.get(fs_type)): - self.app.status = ops.BlockedStatus(f"Missing configuration for mount type `{fs_type}.") + self.app.status = ops.BlockedStatus( + f"Missing configuration for mount type `{fs_type}." + ) return options["uri"] = share.uri mountpoint = options["mountpoint"] - opts = list() + opts = [] opts.append("noexec" if options.get("noexec") else "exec") opts.append("nosuid" if options.get("nosuid") else "suid") opts.append("nodev" if options.get("nodev") else "dev") @@ -106,8 +112,8 @@ def _handle_event(self, event: ops.EventBase) -> None: if not (mount := mounts.get(fs_type)) or mount != options: # Just in case, unmount the previously mounted share if mount: - self._mount_manager.umount(mount["mountpoint"]) - self._mount_manager.mount(share, mountpoint, options=opts) + self._mounts_manager.umount(mount["mountpoint"]) + self._mounts_manager.mount(share.fs_info, mountpoint, options=opts) mounts[fs_type] = options self.unit.status = ops.ActiveStatus("Mounted shares.") @@ -116,9 +122,10 @@ def _handle_event(self, event: ops.EventBase) -> None: def peers(self) -> Optional[ops.Relation]: """Fetch the peer relation.""" return self.model.get_relation(PEER_NAME) - + @contextmanager def mounts(self) -> Generator[dict[str, dict[str, str | bool]], None, None]: + """Get the mounted filesystems.""" mounts = self.get_state("mounts") yield mounts # Don't set the state if the program throws an error. @@ -127,6 +134,10 @@ def mounts(self) -> Generator[dict[str, dict[str, str | bool]], None, None]: def set_state(self, key: str, data: Any) -> None: """Insert a value into the global state.""" + if not self.peers: + raise RuntimeError( + "Peer relation can only be written to after the relation is established" + ) self.peers.data[self.app][key] = json.dumps(data) def get_state(self, key: str) -> dict[Any, Any]: @@ -139,4 +150,4 @@ def get_state(self, key: str) -> dict[Any, Any]: if __name__ == "__main__": # pragma: nocover - ops.main(StorageClientCharm) # type: ignore + ops.main(FilesystemClientCharm) # type: ignore diff --git a/src/utils/manager.py b/src/utils/manager.py index fac7eeb..423df74 100644 --- a/src/utils/manager.py +++ b/src/utils/manager.py @@ -14,7 +14,7 @@ import charms.operator_libs_linux.v0.apt as apt import charms.operator_libs_linux.v1.systemd as systemd -from charms.storage_client.v0.fs_interfaces import NfsInfo, FsInfo +from charms.filesystem_client.v0.interfaces import CephfsInfo, FsInfo, NfsInfo _logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ def message(self): return self.args[0] def __repr__(self): - """String representation of the error.""" + """Return the string representation of the error.""" return f"<{type(self).__module__}.{type(self).__name__} {self.args}>" @@ -53,7 +53,13 @@ class MountInfo: passno: str -class MountManager: +class MountsManager: + """Manager for mounted filesystems in the current system.""" + + def __init__(self): + # Lazily initialized + self._pkgs = None + @property def _packages(self) -> List[apt.DebianPackage]: if not self._pkgs: @@ -262,7 +268,17 @@ def _get_endpoint_and_opts(info: FsInfo) -> tuple[str, [str]]: endpoint = f"{hostname}:{path}" options = [f"port={port}"] if port else [] - - return endpoint, options + case CephfsInfo( + fsid=fsid, name=name, path=path, monitor_hosts=mons, user=user, key=secret + ): + mon_addr = "/".join(mons) + endpoint = f"{user}@{fsid}.{name}={path}" + options = [ + "fstype=ceph", + f"mon_addr={mon_addr}", + f"secret={secret}", + ] case _: - raise Error("unsupported filesystem type") + raise Error(f"unsupported filesystem type `{info.fs_type()}`") + + return endpoint, options diff --git a/tests/integration/server/charmcraft.yaml b/tests/integration/server/charmcraft.yaml index b070265..92cbf7b 100644 --- a/tests/integration/server/charmcraft.yaml +++ b/tests/integration/server/charmcraft.yaml @@ -1,21 +1,20 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -name: storage-server +name: filesystem-server type: charm -title: Storage server +title: Filesystem server -summary: Storage server +summary: Filesystem server -description: Storage server +description: Filesystem server -bases: - - build-on: - - name: ubuntu - channel: "22.04" - run-on: - - name: ubuntu - channel: "22.04" +base: ubuntu@24.04 +platforms: + amd64: + +parts: + charm: {} peers: server-peers: @@ -25,3 +24,8 @@ provides: fs-share: interface: fs_share limit: 1 + +config: + options: + type: + type: string \ No newline at end of file diff --git a/tests/integration/server/lib/charms/filesystem_client/v0/interfaces.py b/tests/integration/server/lib/charms/filesystem_client/v0/interfaces.py new file mode 100644 index 0000000..4927980 --- /dev/null +++ b/tests/integration/server/lib/charms/filesystem_client/v0/interfaces.py @@ -0,0 +1,673 @@ +# Copyright 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Library to manage integrations between filesystem providers and consumers. + +This library contains the FsProvides and FsRequires classes for managing an +integration between a filesystem server operator and a filesystem client operator. + +## FsInfo (filesystem mount information) + +This abstract class defines the methods that a filesystem type must expose for providers and +consumers. Any subclass of this class will be compatible with the other methods exposed +by the interface library, but the server and the client are the ones responsible for deciding which +filesystems to support. + +## FsRequires (filesystem client) + +This class provides a uniform interface for charms that need to mount or unmount filesystems, +and convenience methods for consuming data sent by a filesystem server charm. + +### Defined events + +- `mount_fs`: Event emitted when the filesystem is ready to be mounted. +- `umount_fs`: Event emitted when the filesystem needs to be unmounted. + +### Example + +``python +import ops +from charms.storage_libs.v0.fs_interfaces import ( + FsRequires, + MountFsEvent, +) + + +class StorageClientCharm(ops.CharmBase): + # Application charm that needs to mount filesystems. + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + # Charm events defined in the FsRequires class. + self._fs = FsRequires(self, "fs-share") + self.framework.observe( + self._fs.on.mount_fs, + self._on_mount_fs, + ) + + def _on_mount_fs(self, event: MountShareEvent) -> None: + # Handle when new filesystem server is connected. + + fs_info = event.fs_info + + self.mount("/mnt", fs_info) + + self.unit.status = ops.ActiveStatus("Mounted filesystem at `/mnt`.") +``` + +## FsProvides (filesystem server) + +This library provides a uniform interface for charms that expose filesystems. + +> __Note:__ It is the responsibility of the provider charm to have +> the implementation for creating a new filesystem share. FsProvides just exposes +> the interface for the integration. + +### Example + +```python +import ops +from charms.filesystem_client.v0.fs_interfaces import ( + FsProvides, + NfsInfo, +) + +class StorageServerCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self._fs = FsProvides(self, "fs-share", "server-peers") + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event: ops.StartEvent): + # Handle start event. + self._fs.set_fs_info(NfsInfo("192.168.1.254", 65535, "/srv")) + self.unit.status = ops.ActiveStatus() +``` +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from ipaddress import AddressValueError, IPv6Address +from typing import List, Optional, TypeVar +from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunsplit + +import ops +from ops.charm import ( + CharmBase, + CharmEvents, + RelationChangedEvent, + RelationDepartedEvent, + RelationEvent, + RelationJoinedEvent, +) +from ops.framework import EventSource, Object +from ops.model import Model, Relation + +__all__ = [ + "InterfacesError", + "ParseError", + "FsInfo", + "NfsInfo", + "CephfsInfo", + "Endpoint", + "MountFsEvent", + "UmountFsEvent", + "FsRequires", + "FsProvides", +] + +# The unique Charmhub library identifier, never change it +LIBID = "todo" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +_logger = logging.getLogger(__name__) + + +class InterfacesError(Exception): + """Exception raised when an operation failed.""" + + +class ParseError(InterfacesError): + """Exception raised when a parse operation from an URI failed.""" + + +# Design-wise, this class represents the grammar that relations use to +# share data between providers and requirers: +# +# key = 1*( unreserved ) +# value = 1*( unreserved / ":" / "/" / "?" / "#" / "[" / "]" / "@" / "!" / "$" +# / "'" / "(" / ")" / "*" / "+" / "," / ";" ) +# options = key "=" value ["&" options] +# host-port = host [":" port] +# hosts = host-port [',' hosts] +# authority = [userinfo "@"] "(" hosts ")" +# URI = scheme "://" authority path-absolute ["?" options] +# +# Unspecified grammar rules are given by [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#appendix-A). +# +# This essentially leaves 5 components that the library can use to share data: +# - scheme: representing the type of filesystem. +# - hosts: representing the list of hosts where the filesystem lives. For NFS it should be a single element, +# but CephFS and Lustre use more than one endpoint. +# - user: Any kind of authentication user that the client must specify to mount the filesystem. +# - path: The internally exported path of each filesystem. Could be optional if a filesystem exports its +# whole tree, but at the very least NFS, CephFS and Lustre require an export path. +# - options: Some filesystems will require additional options for its specific mount command (e.g. Ceph). +# +# Putting all together, this allows sharing the required data using simple URI strings: +# ``` +# ://@(,*)//? +# +# nfs://(192.168.1.1:65535)/export +# ceph://fsuser@(192.168.1.1,192.168.1.2,192.168.1.3)/export?fsid=asdf1234&auth=plain:QWERTY1234&filesystem=fs_name +# ceph://fsuser@(192.168.1.1,192.168.1.2,192.168.1.3)/export?fsid=asdf1234&auth=secret:YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy&filesystem=fs_name +# lustre://(192.168.227.11%40tcp1,192.168.227.12%40tcp1)/export +# ``` +# +# Note how in the Lustre URI we needed to escape the `@` symbol on the hosts to conform with the URI syntax. +@dataclass(init=False, frozen=True) +class _UriData: + """Raw data from the endpoint URI of a relation.""" + + scheme: str + """Scheme used to identify a filesystem. + + This will mostly correspond to the option `fstype` for the `mount` command. + """ + + hosts: [str] + """List of hosts where the filesystem is deployed on.""" + + user: str + """User to connect to the filesystem.""" + + path: str + """Path exported by the filesystem.""" + + options: dict[str, str] + """Additional options that could be required to mount the filesystem.""" + + def __init__( + self, + scheme: str, + hosts: [str], + user: str = "", + path: str = "/", + options: dict[str, str] = {}, + ): + if not scheme: + raise InterfacesError("scheme cannot be empty") + if len(hosts) == 0: + raise InterfacesError("list of hosts cannot be empty") + + # Strictly convert to the required types to avoid passing through weird data. + object.__setattr__(self, "scheme", str(scheme)) + object.__setattr__(self, "hosts", [str(host) for host in hosts]) + object.__setattr__(self, "user", str(user) if user else "") + object.__setattr__(self, "path", str(path) if path else "/") + object.__setattr__( + self, "options", {str(k): str(v) for k, v in options.items()} if options else {} + ) + + @classmethod + def from_uri(cls, uri: str) -> "_UriData": + """Convert an URI string into a `_UriData`.""" + _logger.debug(f"_UriData.from_uri: parsing `{uri}`") + + result = urlparse(uri, allow_fragments=False) + scheme = str(result.scheme or "") + user = unquote(result.username or "") + hostname = unquote(result.hostname or "") + + if not hostname or hostname[0] != "(" or hostname[-1] != ")": + raise ParseError(f"invalid list of hosts for endpoint `{uri}`") + + hosts = hostname[1:-1].split(",") + path = unquote(result.path or "") + try: + options = { + key: ",".join(values) + for key, values in parse_qs(result.query, strict_parsing=True).items() + } + except ValueError: + raise ParseError(f"invalid options for endpoint `{uri}`") + try: + return _UriData(scheme=scheme, user=user, hosts=hosts, path=path, options=options) + except InterfacesError as e: + raise ParseError(*e.args) + + def __str__(self) -> str: + user = quote(self.user) + hostname = quote(",".join(self.hosts)) + path = quote(self.path) + netloc = f"{user}@({hostname})" if user else f"({hostname})" + query = urlencode(self.options) + return urlunsplit((self.scheme, netloc, path, query, None)) + + +def _hostinfo(host: str) -> tuple[str, Optional[int]]: + """Parse a host string into the hostname and the port.""" + _logger.debug(f"_hostinfo: parsing `{host}`") + if len(host) == 0: + raise ParseError("invalid empty host") + + pos = 0 + if host[pos] == "[": + # IPv6 + pos = host.find("]", pos) + if pos == -1: + raise ParseError("unclosed bracket for host") + hostname = host[1:pos] + pos = pos + 1 + else: + # IPv4 or DN + pos = host.find(":", pos) + if pos == -1: + pos = len(host) + hostname = host[:pos] + + if pos == len(host): + return hostname, None + + # more characters after the hostname <==> port + + if host[pos] != ":": + raise ParseError("expected `:` after IPv6 address") + try: + port = int(host[pos + 1 :]) + except ValueError: + raise ParseError("expected int after `:` in host") + + return hostname, port + + +T = TypeVar("T", bound="FsInfo") + + +class FsInfo(ABC): + """Information to mount a filesystem. + + This is an abstract class that exposes a set of required methods. All filesystems that + can be handled by this library must derive this abstract class. + """ + + @classmethod + @abstractmethod + def from_uri(cls: type[T], uri: str, model: Model) -> T: + """Convert an URI string into a `FsInfo` object.""" + + @abstractmethod + def to_uri(self, model: Model) -> str: + """Convert this `FsInfo` object into an URI string.""" + + def grant(self, model: Model, relation: ops.Relation): + """Grant permissions for a certain relation to any secrets that this `FsInfo` has. + + This is an optional method because not all filesystems will require secrets to + be mounted on the client. + """ + + @classmethod + @abstractmethod + def fs_type(cls) -> str: + """Get the string identifier of this filesystem type.""" + + +@dataclass(frozen=True) +class NfsInfo(FsInfo): + """Information required to mount an NFS share.""" + + hostname: str + """Hostname where the NFS server can be reached.""" + + port: Optional[int] + """Port where the NFS server can be reached.""" + + path: str + """Path exported by the NFS server.""" + + @classmethod + def from_uri(cls, uri: str, _model: Model) -> "NfsInfo": + """See :py:meth:`FsInfo.from_uri` for documentation on this method.""" + _logger.debug(f"NfsInfo.from_uri: parsing `{uri}`") + + info = _UriData.from_uri(uri) + + if info.scheme != cls.fs_type(): + raise ParseError( + "could not parse `EndpointInfo` with incompatible scheme into `NfsInfo`" + ) + + path = info.path + + if info.user: + _logger.warning("ignoring user info on nfs endpoint info") + + if len(info.hosts) > 1: + _logger.info("multiple hosts specified. selecting the first one") + + if info.options: + _logger.warning("ignoring endpoint options on nfs endpoint info") + + hostname, port = _hostinfo(info.hosts[0]) + return NfsInfo(hostname=hostname, port=port, path=path) + + def to_uri(self, _model: Model) -> str: + """See :py:meth:`FsInfo.to_uri` for documentation on this method.""" + try: + IPv6Address(self.hostname) + host = f"[{self.hostname}]" + except AddressValueError: + host = self.hostname + + hosts = [host + f":{self.port}" if self.port else ""] + + return str(_UriData(scheme=self.fs_type(), hosts=hosts, path=self.path)) + + @classmethod + def fs_type(cls) -> str: + """See :py:meth:`FsInfo.fs_type` for documentation on this method.""" + return "nfs" + + +@dataclass(frozen=True) +class CephfsInfo(FsInfo): + """Information required to mount a CephFS share.""" + + fsid: str + """Cluster identifier.""" + + name: str + """Name of the exported filesystem.""" + + path: str + """Path exported within the filesystem.""" + + monitor_hosts: [str] + """List of reachable monitor hosts.""" + + user: str + """Ceph user authorized to access the filesystem.""" + + key: str + """Cephx key for the authorized user.""" + + @classmethod + def from_uri(cls, uri: str, model: Model) -> "CephfsInfo": + """See :py:meth:`FsInfo.from_uri` for documentation on this method.""" + _logger.debug(f"CephfsInfo.from_uri: parsing `{uri}`") + info = _UriData.from_uri(uri) + + if info.scheme != cls.fs_type(): + raise ParseError( + "could not parse `EndpointInfo` with incompatible scheme into `CephfsInfo`" + ) + + path = info.path + + if not (user := info.user): + raise ParseError("missing user in uri for `CephfsInfo") + + if not (name := info.options.get("name")): + raise ParseError("missing name in uri for `CephfsInfo`") + + if not (fsid := info.options.get("fsid")): + raise ParseError("missing fsid in uri for `CephfsInfo`") + + monitor_hosts = info.hosts + + if not (auth := info.options.get("auth")): + raise ParseError("missing auth info in uri for `CephsInfo`") + + try: + kind, data = auth.split(":", 1) + except ValueError: + raise ParseError("Could not get the kind of auth info") + + if kind == "secret": + key = model.get_secret(id=auth).get_content(refresh=True)["key"] + elif kind == "plain": + # Enables being able to pass data from reactive charms (such as `ceph-fs`), since + # they don't support secrets. + key = data + else: + raise ParseError(f"Invalid kind `{kind}` for auth info") + + return CephfsInfo( + fsid=fsid, name=name, path=path, monitor_hosts=monitor_hosts, user=user, key=key + ) + + def to_uri(self, model: Model) -> str: + """See :py:meth:`FsInfo.to_uri` for documentation on this method.""" + secret = self._get_or_create_auth_secret(model) + + options = { + "fsid": self.fsid, + "name": self.name, + "auth": secret.id, + "auth-rev": str(secret.get_info().revision), + } + + return str( + _UriData( + scheme=self.fs_type(), + hosts=self.monitor_hosts, + path=self.path, + user=self.user, + options=options, + ) + ) + + def grant(self, model: Model, relation: Relation): + """See :py:meth:`FsInfo.grant` for documentation on this method.""" + secret = self._get_or_create_auth_secret(model) + + secret.grant(relation) + + @classmethod + def fs_type(cls) -> str: + """See :py:meth:`FsInfo.fs_type` for documentation on this method.""" + return "cephfs" + + def _get_or_create_auth_secret(self, model: Model) -> ops.Secret: + try: + secret = model.get_secret(label="auth") + secret.set_content({"key": self.key}) + except ops.SecretNotFoundError: + secret = model.app.add_secret( + {"key": self.key}, + label="auth", + description="Cephx key to authenticate against the CephFS share", + ) + return secret + + +@dataclass +class Endpoint: + """Endpoint data exposed by a filesystem server.""" + + fs_info: FsInfo + """Filesystem information required to mount this endpoint.""" + + uri: str + """Raw URI exposed by this endpoint.""" + + +def _uri_to_fs_info(uri: str, model: Model) -> FsInfo: + scheme = uri.split("://", maxsplit=1)[0] + if scheme == NfsInfo.fs_type(): + return NfsInfo.from_uri(uri, model) + elif scheme == CephfsInfo.fs_type(): + return CephfsInfo.from_uri(uri, model) + else: + raise InterfacesError(f"unsupported filesystem type `{scheme}`") + + +class _MountEvent(RelationEvent): + """Base event for mount-related events.""" + + @property + def endpoint(self) -> Optional[Endpoint]: + """Get endpoint info.""" + if not (uri := self.relation.data[self.relation.app].get("endpoint")): + return + return Endpoint(_uri_to_fs_info(uri, self.framework.model), uri) + + +class MountFsEvent(_MountEvent): + """Emit when a filesystem is ready to be mounted.""" + + +class UmountFsEvent(_MountEvent): + """Emit when a filesystem needs to be unmounted.""" + + +class _FsRequiresEvents(CharmEvents): + """Events that FS servers can emit.""" + + mount_fs = EventSource(MountFsEvent) + umount_fs = EventSource(UmountFsEvent) + + +class _BaseInterface(Object): + """Base methods required for filesystem integration interfaces.""" + + def __init__(self, charm: CharmBase, relation_name) -> None: + super().__init__(charm, relation_name) + self.charm = charm + self.app = charm.model.app + self.unit = charm.unit + self.relation_name = relation_name + + @property + def relations(self) -> List[Relation]: + """Get list of active relations associated with the relation name.""" + result = [] + for relation in self.charm.model.relations[self.relation_name]: + try: + # Exclude relations that don't have any data yet + _ = repr(relation.data) + result.append(relation) + except RuntimeError: + continue + return result + + +class FsRequires(_BaseInterface): + """Consumer-side interface of filesystem integrations.""" + + on = _FsRequiresEvents() + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + super().__init__(charm, relation_name) + self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) + self.framework.observe( + charm.on[relation_name].relation_departed, self._on_relation_departed + ) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle when the databag between client and server has been updated.""" + _logger.debug("Emitting `MountShare` event from `RelationChanged` hook") + self.on.mount_fs.emit(event.relation, app=event.app, unit=event.unit) + + def _on_relation_departed(self, event: RelationDepartedEvent) -> None: + """Handle when server departs integration.""" + _logger.debug("Emitting `UmountShare` event from `RelationDeparted` hook") + self.on.umount_fs.emit(event.relation, app=event.app, unit=event.unit) + + @property + def endpoints(self) -> List[Endpoint]: + """List of endpoints exposed by all the relations of this charm.""" + result = [] + for relation in self.relations: + if not (uri := relation.data[relation.app].get("endpoint")): + continue + result.append(Endpoint(fs_info=_uri_to_fs_info(uri, self.model), uri=uri)) + return result + + +class FsProvides(_BaseInterface): + """Provider-side interface of filesystem integrations.""" + + def __init__(self, charm: CharmBase, relation_name: str, peer_relation_name: str) -> None: + super().__init__(charm, relation_name) + self._peer_relation_name = peer_relation_name + self.framework.observe(charm.on[relation_name].relation_joined, self._update_relation) + + def set_fs_info(self, fs_info: FsInfo) -> None: + """Set information to mount a filesystem. + + Args: + fs_info: Information required to mount the filesystem. + + Notes: + Only the application leader unit can set the filesystem data. + """ + if not self.unit.is_leader(): + return + + uri = fs_info.to_uri(self.model) + + self._endpoint = uri + + for relation in self.relations: + fs_info.grant(self.model, relation) + relation.data[self.app]["endpoint"] = uri + + def _update_relation(self, event: RelationJoinedEvent) -> None: + if not self.unit.is_leader() or not (endpoint := self._endpoint): + return + + fs_info = _uri_to_fs_info(endpoint, self.model) + fs_info.grant(self.model, event.relation) + + event.relation.data[self.app]["endpoint"] = endpoint + + @property + def _peers(self) -> Optional[ops.Relation]: + """Fetch the peer relation.""" + return self.model.get_relation(self._peer_relation_name) + + @property + def _endpoint(self) -> str: + endpoint = self._get_state("endpoint") + return "" if endpoint is None else endpoint + + @_endpoint.setter + def _endpoint(self, endpoint: str) -> None: + self._set_state("endpoint", endpoint) + + def _get_state(self, key: str) -> Optional[str]: + """Get a value from the global state.""" + if not self._peers: + return None + + return self._peers.data[self.app].get(key) + + def _set_state(self, key: str, data: str) -> None: + """Insert a value into the global state.""" + if not self._peers: + raise InterfacesError( + "Peer relation can only be accessed after the relation is established" + ) + + self._peers.data[self.app][key] = data diff --git a/tests/integration/server/lib/charms/storage_client/v0/fs_interfaces.py b/tests/integration/server/lib/charms/storage_client/v0/fs_interfaces.py deleted file mode 100644 index ed5554f..0000000 --- a/tests/integration/server/lib/charms/storage_client/v0/fs_interfaces.py +++ /dev/null @@ -1,365 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" """ - -import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass -from ipaddress import AddressValueError, IPv6Address -from typing import Dict, List, Optional, TypeVar -from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunsplit - -import ops -from ops.charm import ( - CharmBase, - CharmEvents, - RelationChangedEvent, - RelationDepartedEvent, - RelationEvent, - RelationJoinedEvent, -) -from ops.framework import EventSource, Object -from ops.model import Model, Relation - -__all__ = [ - "FsInterfacesError", - "ParseError", - "Share", - "ShareInfo", - "NfsInfo", - "MountShareEvent", - "UmountShareEvent", - "FSRequires", - "FSProvides", -] - -# The unique Charmhub library identifier, never change it -LIBID = "todo" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 1 - -_logger = logging.getLogger(__name__) - - -@dataclass -class _UriData: - scheme: str - user: str - hosts: [str] - path: str - options: Dict[str, str] - - -def _parse_uri(uri: str) -> _UriData: - _logger.debug(f"parsing `{uri}`") - - uri = urlparse(uri, allow_fragments=False) - scheme = str(uri.scheme) - if not scheme: - raise ParseError("scheme cannot be empty") - user = unquote(uri.username) - hostname = unquote(uri.hostname) - - if not hostname or hostname[0] != "(" or hostname[-1] != ")": - _logger.debug(f"parsing failed for hostname `{hostname}`") - raise ParseError("invalid list of hosts for endpoint") - - hosts = hostname.split(",") - if len(hosts) == 0: - raise ParseError("list of hosts cannot be empty") - path = uri.path - if not path: - raise ParseError("path cannot be empty") - try: - options = parse_qs(uri.query, strict_parsing=True) - except ValueError: - _logger.debug(f"parsing failed for query `{uri.query}`") - raise ParseError("invalid options for endpoint info") - - return _UriData(scheme=scheme, user=user, hosts=hosts, path=path, options=options) - - -def _to_uri(scheme: str, hosts: [str], path: str, user="", options: Dict[str, str] = {}) -> str: - user = quote(user) - hostname = quote(",".join(hosts)) - netloc = f"{user}@" if user else "" + f"({hostname})" - query = urlencode(options) - - return urlunsplit((scheme, netloc, path, query, None)) - - -def _hostinfo(host: str) -> tuple[str, Optional[int]]: - # IPv6 - if host.startswith("["): - parts = iter(host[1:].split("]", maxsplit=1)) - host = next(parts) - - if (port := next(parts, None)) is None: - raise ParseError("unclosed bracket for host") - - if not port: - return host, None - - if not port.startswith(":"): - raise ParseError("invalid syntax for host") - - try: - port = int(port[1:]) - except ValueError: - raise ParseError("invalid port on host") - - return host, port - - # IPv4 or hostname - parts = iter(host.split(":", maxsplit=1)) - host = next(parts) - if (port := next(parts, None)) is None: - return host, None - - try: - port = int(port) - except ValueError: - raise ParseError("invalid port on host") - - return host, port - - -class FsInterfacesError(Exception): - """Exception raised when a filesystem operation failed.""" - - -class ParseError(FsInterfacesError): ... - - -T = TypeVar("T", bound="ShareInfo") - - -class ShareInfo(ABC): - @classmethod - @abstractmethod - def from_uri(cls: type[T], uri: str, model: Model) -> T: ... - - @abstractmethod - def to_uri(self, model: Model) -> str: ... - - @classmethod - @abstractmethod - def fs_type(cls) -> str: ... - - -@dataclass(frozen=True) -class NfsInfo(ShareInfo): - hostname: str - port: Optional[int] - path: str - - @classmethod - def from_uri(cls, uri: str, _model: Model) -> "NfsInfo": - info = _parse_uri(uri) - - if info.scheme != "nfs": - raise ParseError( - "could not parse `EndpointInfo` with incompatible scheme into `NfsInfo`" - ) - - if info.user: - _logger.warning("ignoring user info on nfs endpoint info") - - if len(info.hosts) > 1: - _logger.info("multiple hosts specified. selecting the first one") - - if info.options: - _logger.warning("ignoring endpoint options on nfs endpoint info") - - hostname, port = _hostinfo(info.hosts[0]) - path = info.path - return NfsInfo(hostname=hostname, port=port, path=path) - - def to_uri(self, _model: Model) -> str: - try: - IPv6Address(self.hostname) - host = f"[{self.hostname}]" - except AddressValueError: - host = self.hostname - - hosts = [host + f":{self.port}" if self.port else ""] - - return _to_uri(scheme="nfs", hosts=hosts, path=self.path) - - @classmethod - def fs_type(cls) -> str: - return "nfs" - - -def _uri_to_share_info(uri: str, model: Model) -> ShareInfo: - match uri.split("://", maxsplit=1)[0]: - case "nfs": - return NfsInfo.from_uri(uri, model) - case _: - raise FsInterfacesError("unsupported share type") - - -class _MountEvent(RelationEvent): - """Base event for mount-related events.""" - - @property - def share_info(self) -> Optional[ShareInfo]: - """Get mount info.""" - if not (uri := self.relation.data[self.relation.app].get("endpoint")): - return - return _uri_to_share_info(uri, self.framework.model) - - -class MountShareEvent(_MountEvent): - """Emit when FS share is ready to be mounted.""" - - -class UmountShareEvent(_MountEvent): - """Emit when FS share needs to be unmounted.""" - - -class _FSRequiresEvents(CharmEvents): - """Events that FS servers can emit.""" - - mount_share = EventSource(MountShareEvent) - umount_share = EventSource(UmountShareEvent) - -@dataclass -class Share: - info: ShareInfo - uri: str - -class _BaseInterface(Object): - """Base methods required for FS share integration interfaces.""" - - def __init__(self, charm: CharmBase, relation_name) -> None: - super().__init__(charm, relation_name) - self.charm = charm - self.app = charm.model.app - self.unit = charm.unit - self.relation_name = relation_name - - @property - def relations(self) -> List[Relation]: - """Get list of active relations associated with the relation name.""" - result = [] - for relation in self.charm.model.relations[self.relation_name]: - try: - _ = repr(relation.data) - result.append(relation) - except RuntimeError: - pass - return result - - -class FSRequires(_BaseInterface): - """Consumer-side interface of FS share integrations.""" - - on = _FSRequiresEvents() - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) - self.framework.observe( - charm.on[relation_name].relation_departed, self._on_relation_departed - ) - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle when the databag between client and server has been updated.""" - _logger.debug("Emitting `MountShare` event from `RelationChanged` hook") - self.on.mount_share.emit(event.relation, app=event.app, unit=event.unit) - - def _on_relation_departed(self, event: RelationDepartedEvent) -> None: - """Handle when server departs integration.""" - _logger.debug("Emitting `UmountShare` event from `RelationDeparted` hook") - self.on.umount_share.emit(event.relation, app=event.app, unit=event.unit) - - @property - def shares(self) -> List[Share]: - result = [] - for relation in self.relations: - if not (uri := relation.data[relation.app].get("endpoint")): - pass - result.append(Share(info=_uri_to_share_info(uri, self.model), uri=uri)) - return result - - -class FSProvides(_BaseInterface): - """Provider-side interface of FS share integrations.""" - - def __init__(self, charm: CharmBase, relation_name: str, peer_relation_name: str) -> None: - super().__init__(charm, relation_name) - self._peer_relation_name = peer_relation_name - self.framework.observe(charm.on[relation_name].relation_joined, self._update_relation) - - def set_share(self, share_info: ShareInfo) -> None: - """Set info for mounting a FS share. - - Args: - share_info: Information required to mount the FS share. - - Notes: - Only the application leader unit can set the FS share data. - """ - if not self.unit.is_leader(): - return - - uri = share_info.to_uri(self.charm.model) - - self._endpoint = uri - - for relation in self.relations: - relation.data[self.app]["endpoint"] = uri - - def _update_relation(self, event: RelationJoinedEvent) -> None: - if not (endpoint := self._endpoint): - return - - event.relation.data[self.app]["endpoint"] = endpoint - - @property - def _peers(self) -> Optional[ops.Relation]: - """Fetch the peer relation.""" - return self.model.get_relation(self._peer_relation_name) - - @property - def _endpoint(self) -> str: - endpoint = self._get_state("endpoint") - return "" if endpoint is None else endpoint - - @_endpoint.setter - def _endpoint(self, endpoint: str) -> None: - self._set_state("endpoint", endpoint) - - def _get_state(self, key: str) -> Optional[str]: - """Get a value from the global state.""" - if not self._peers: - return None - - return self._peers.data[self.app].get(key) - - def _set_state(self, key: str, data: str) -> None: - """Insert a value into the global state.""" - if not self._peers: - raise FsInterfacesError( - "Peer relation can only be accessed after the relation is established" - ) - - self._peers.data[self.app][key] = data diff --git a/tests/integration/server/src/charm.py b/tests/integration/server/src/charm.py index 2da282f..c9c3a46 100755 --- a/tests/integration/server/src/charm.py +++ b/tests/integration/server/src/charm.py @@ -7,12 +7,27 @@ import logging import ops -from charms.storage_client.v0.fs_interfaces import FsProvides, NfsInfo +from charms.filesystem_client.v0.interfaces import CephfsInfo, FsProvides, NfsInfo _logger = logging.getLogger(__name__) +NFS_INFO = NfsInfo(hostname="192.168.1.254", path="/srv", port=65535) -class StorageServerCharm(ops.CharmBase): +CEPHFS_INFO = CephfsInfo( + fsid="123456789-0abc-defg-hijk-lmnopqrstuvw", + name="filesystem", + path="/export", + monitor_hosts=[ + "192.168.1.1:6789", + "192.168.1.2:6789", + "192.168.1.3:6789", + ], + user="user", + key="R//appdqz4NP4Bxcc5XWrg==", +) + + +class FilesystemServerCharm(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) self._fs_share = FsProvides(self, "fs-share", "server-peers") @@ -20,9 +35,20 @@ def __init__(self, framework: ops.Framework): def _on_start(self, event: ops.StartEvent): """Handle start event.""" - self._fs_share.set_fs_info(NfsInfo("192.168.1.254", 65535, "/srv")) + _logger.info(self.config.get("type")) + typ = self.config["type"] + if "nfs" == typ: + info = NFS_INFO + elif "cephfs" == typ: + info = CEPHFS_INFO + else: + raise ValueError("invalid filesystem type") + + self._fs_share.set_fs_info(info) + _logger.info("set info") self.unit.status = ops.ActiveStatus() + _logger.info("transitioned to active") if __name__ == "__main__": # pragma: nocover - ops.main(StorageServerCharm) # type: ignore + ops.main(FilesystemServerCharm) # type: ignore diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 369b95a..5992f87 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -8,6 +8,7 @@ import pytest import yaml +from charms.filesystem_client.v0.interfaces import CephfsInfo, NfsInfo from pytest_operator.plugin import OpsTest logger = logging.getLogger(__name__) @@ -15,8 +16,23 @@ METADATA = yaml.safe_load(Path("./charmcraft.yaml").read_text()) APP_NAME = METADATA["name"] +NFS_INFO = NfsInfo(hostname="192.168.1.254", path="/srv", port=65535) +CEPHFS_INFO = CephfsInfo( + fsid="123456789-0abc-defg-hijk-lmnopqrstuvw", + name="filesystem", + path="/export", + monitor_hosts=[ + "192.168.1.1:6789", + "192.168.1.2:6789", + "192.168.1.3:6789", + ], + user="user", + key="R//appdqz4NP4Bxcc5XWrg==", +) + @pytest.mark.abort_on_fail +@pytest.mark.order(1) async def test_build_and_deploy(ops_test: OpsTest): """Build the charm-under-test and deploy it together with related charms. @@ -28,7 +44,7 @@ async def test_build_and_deploy(ops_test: OpsTest): # Deploy the charm and wait for active/idle status await asyncio.gather( - ops_test.model.deploy("ubuntu", application_name="ubuntu"), + ops_test.model.deploy("ubuntu", application_name="ubuntu", base="ubuntu@24.04"), ops_test.model.deploy( charm, application_name=APP_NAME, @@ -37,23 +53,100 @@ async def test_build_and_deploy(ops_test: OpsTest): "mountinfo": """ { "nfs": { - "mountpoint": "/data", + "mountpoint": "/nfs", "nodev": true, "read-only": true + }, + "cephfs": { + "mountpoint": "/cephfs", + "noexec": true, + "nosuid": true, + "nodev": false } } """ }, ), - ops_test.model.deploy(server, application_name="server"), + ops_test.model.deploy( + server, + application_name="nfs-server", + config={ + "type": "nfs", + }, + ), + ops_test.model.deploy( + server, + application_name="cephfs-server", + config={ + "type": "cephfs", + }, + ), ops_test.model.wait_for_idle( - apps=["server", "ubuntu"], status="active", raise_on_blocked=True, timeout=1000 + apps=["nfs-server", "cephfs-server", "ubuntu"], + status="active", + raise_on_blocked=True, + timeout=1000, ), ) + +@pytest.mark.abort_on_fail +@pytest.mark.order(2) +async def test_integrate(ops_test: OpsTest): await ops_test.model.integrate(f"{APP_NAME}:juju-info", "ubuntu:juju-info") - await ops_test.model.integrate(f"{APP_NAME}:fs-share", "server:fs-share") + await ops_test.model.integrate(f"{APP_NAME}:fs-share", "nfs-server:fs-share") + await ops_test.model.integrate(f"{APP_NAME}:fs-share", "cephfs-server:fs-share") await ops_test.model.wait_for_idle( - apps=[APP_NAME, "ubuntu", "server"], status="active", timeout=1000 + apps=[APP_NAME, "ubuntu", "nfs-server", "cephfs-server"], status="active", timeout=1000 + ) + + +async def check_autofs( + ops_test: OpsTest, + master_file: str, + autofs_file: str, + mountpoint: str, + opts: [str], + endpoint: str, +): + unit = ops_test.model.applications["ubuntu"].units[0] + master = (await unit.ssh(f"cat {master_file}")).split() + mount = (await unit.ssh(f"cat {autofs_file}")).split() + + assert autofs_file == master[1] + assert mountpoint == mount[0] + assert set(opts) == set(mount[1].removeprefix("-").split(",")) + assert endpoint == mount[2] + + +@pytest.mark.order(3) +async def test_nfs_files(ops_test: OpsTest): + await check_autofs( + ops_test=ops_test, + master_file="/etc/auto.master.d/nfs.autofs", + autofs_file="/etc/auto.nfs", + mountpoint="/nfs", + opts=["exec", "suid", "nodev", "ro", f"port={NFS_INFO.port}"], + endpoint=f"{NFS_INFO.hostname}:{NFS_INFO.path}", + ) + + +@pytest.mark.order(4) +async def test_cephfs_files(ops_test: OpsTest): + await check_autofs( + ops_test=ops_test, + master_file="/etc/auto.master.d/cephfs.autofs", + autofs_file="/etc/auto.cephfs", + mountpoint="/cephfs", + opts=[ + "noexec", + "nosuid", + "dev", + "rw", + "fstype=ceph", + f"mon_addr={"/".join(CEPHFS_INFO.monitor_hosts)}", + f"secret={CEPHFS_INFO.key}", + ], + endpoint=f"{CEPHFS_INFO.user}@{CEPHFS_INFO.fsid}.{CEPHFS_INFO.name}={CEPHFS_INFO.path}", ) diff --git a/tests/unit/test_interfaces.py b/tests/unit/test_interfaces.py new file mode 100644 index 0000000..cf30162 --- /dev/null +++ b/tests/unit/test_interfaces.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Test the interfaces charm library.""" + +import pytest +from charms.filesystem_client.v0.interfaces import ( + FsRequires, + _hostinfo, +) +from ops import CharmBase + +FS_INTEGRATION_NAME = "fs-share" +FS_INTEGRATION_INTERFACE = "fs_share" +FS_CLIENT_METADATA = f""" +name: fs-client +requires: + {FS_INTEGRATION_NAME}: + interface: {FS_INTEGRATION_INTERFACE} +""" + +FSID = "123456789-0abc-defg-hijk-lmnopqrstuvw" +NAME = "filesystem" +PATH = "/data" +MONITOR_HOSTS = [("192.168.1.1", 6789), ("192.168.1.2", 6789), ("192.168.1.3", 6789)] +USERNAME = "user" +KEY = "R//appdqz4NP4Bxcc5XWrg==" + + +class FsClientCharm(CharmBase): + """Mock CephFS client charm for unit tests.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.requirer = FsRequires(self, FS_INTEGRATION_NAME) + self.framework.observe(self.requirer.on.mount_fs, lambda *_: None) + self.framework.observe(self.requirer.on.umount_fs, lambda *_: None) + + +class TestInterface: + @pytest.mark.parametrize( + ("host", "parsed"), + [ + ("192.168.1.1", ("192.168.1.1", None)), + ("192.168.1.1:6789", ("192.168.1.1", 6789)), + ("[2001:db8::2:1]", ("2001:db8::2:1", None)), + ("[2001:db8::2:1]:9876", ("2001:db8::2:1", 9876)), + ("192.things.com", ("192.things.com", None)), + ("192.things.com:1234", ("192.things.com", 1234)), + ], + ) + def test_hostinfo(self, host: str, parsed: tuple[str, int | None]): + assert _hostinfo(host) == parsed diff --git a/uv.lock b/uv.lock index 3d49dff..6c6f792 100644 --- a/uv.lock +++ b/uv.lock @@ -207,6 +207,7 @@ source = { virtual = "." } dependencies = [ { name = "jsonschema" }, { name = "ops" }, + { name = "rpds-py" }, ] [package.optional-dependencies] @@ -218,6 +219,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-operator" }, + { name = "pytest-order" }, { name = "ruff" }, ] @@ -232,6 +234,8 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-operator", marker = "extra == 'dev'" }, + { name = "pytest-order", marker = "extra == 'dev'" }, + { name = "rpds-py", specifier = "~=0.22.3" }, { name = "ruff", marker = "extra == 'dev'" }, ] @@ -750,6 +754,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/32/2f8c9e0441607cba71aa5de167534c9bff8720ba5d0ff872a9aa5eea6a12/pytest_operator-0.39.0-py3-none-any.whl", hash = "sha256:ade76e1896eaf7f71704b537fd6661a705d81a045b8db71531d9e4741913fa19", size = 25265 }, ] +[[package]] +name = "pytest-order" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/66/02ae17461b14a52ce5a29ae2900156b9110d1de34721ccc16ccd79419876/pytest_order-1.3.0.tar.gz", hash = "sha256:51608fec3d3ee9c0adaea94daa124a5c4c1d2bb99b00269f098f414307f23dde", size = 47544 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/73/59b038d1aafca89f8e9936eaa8ffa6bb6138d00459d13a32ce070be4f280/pytest_order-1.3.0-py3-none-any.whl", hash = "sha256:2cd562a21380345dd8d5774aa5fd38b7849b6ee7397ca5f6999bbe6e89f07f6e", size = 14609 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"