Skip to content

Commit 49dcec6

Browse files
committed
feat: rework client to only allow a single provider
1 parent b977b35 commit 49dcec6

File tree

8 files changed

+193
-246
lines changed

8 files changed

+193
-246
lines changed

charmcraft.yaml

+22-23
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ parts:
2525
build-snaps:
2626
- astral-uv
2727
charm-requirements: ["requirements.txt"]
28-
charm-binary-python-packages:
29-
- rpds_py ~= 0.22.3
3028
override-build: |
3129
just requirements
3230
craftctl default
@@ -42,32 +40,33 @@ subordinate: true
4240
requires:
4341
filesystem:
4442
interface: filesystem_info
43+
limit: 1
4544
juju-info:
4645
interface: juju-info
4746
scope: container
4847

4948
config:
5049
options:
51-
mounts:
52-
default: "{}"
50+
mountpoint:
51+
description: Location to mount the filesystem on the machine.
5352
type: string
53+
noexec:
54+
default: false
5455
description: |
55-
Information to mount filesystems on the machine. This is specified as a JSON object string.
56-
Example usage:
57-
```bash
58-
$ juju config filesystem-client \
59-
mounts=<<EOF
60-
{
61-
"cephfs": {
62-
"mountpoint": "/scratch",
63-
"noexec": true
64-
},
65-
"nfs": {
66-
"mountpoint": "/data",
67-
"nosuid": true,
68-
"nodev": true,
69-
"read-only": true,
70-
}
71-
}
72-
EOF
73-
```
56+
Block execution of binaries on the filesystem.
57+
type: boolean
58+
nosuid:
59+
default: false
60+
description: |
61+
Do not honour suid and sgid bits on the filesystem.
62+
type: boolean
63+
nodev:
64+
default: false
65+
description: |
66+
Blocking interpretation of character and/or block
67+
devices on the filesystem.
68+
type: boolean
69+
read-only:
70+
default: false
71+
description: Mount filesystem as read-only.
72+
type: boolean

lib/charms/filesystem_client/v0/filesystem_info.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -583,16 +583,14 @@ class FilesystemRequires(_BaseInterface):
583583
def __init__(self, charm: CharmBase, relation_name: str) -> None:
584584
super().__init__(charm, relation_name)
585585
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed)
586-
self.framework.observe(
587-
charm.on[relation_name].relation_departed, self._on_relation_departed
588-
)
586+
self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_broken)
589587

590588
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
591589
"""Handle when the databag between client and server has been updated."""
592590
_logger.debug("Emitting `MountShare` event from `RelationChanged` hook")
593591
self.on.mount_filesystem.emit(event.relation, app=event.app, unit=event.unit)
594592

595-
def _on_relation_departed(self, event: RelationDepartedEvent) -> None:
593+
def _on_relation_broken(self, event: RelationDepartedEvent) -> None:
596594
"""Handle when server departs integration."""
597595
_logger.debug("Emitting `UmountShare` event from `RelationDeparted` hook")
598596
self.on.umount_filesystem.emit(event.relation, app=event.app, unit=event.unit)

pyproject.toml

+1-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ name = "filesystem-client"
33
version = "0.0"
44
requires-python = "==3.12.*"
55
dependencies = [
6-
"ops ~= 2.8",
7-
"jsonschema ~= 4.23.0",
8-
"rpds_py ~= 0.22.3"
6+
"ops ~= 2.8"
97
]
108

119
[project.optional-dependencies]

src/charm.py

+53-60
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,16 @@
44

55
"""Charm for the filesystem client."""
66

7-
import json
87
import logging
9-
from collections import Counter
8+
from dataclasses import dataclass
109

1110
import ops
1211
from charms.filesystem_client.v0.filesystem_info import FilesystemRequires
13-
from jsonschema import ValidationError, validate
1412

1513
from utils.manager import MountsManager
1614

1715
logger = logging.getLogger(__name__)
1816

19-
CONFIG_SCHEMA = {
20-
"$schema": "http://json-schema.org/draft-04/schema#",
21-
"type": "object",
22-
"additionalProperties": {
23-
"type": "object",
24-
"required": ["mountpoint"],
25-
"properties": {
26-
"mountpoint": {"type": "string"},
27-
"noexec": {"type": "boolean"},
28-
"nosuid": {"type": "boolean"},
29-
"nodev": {"type": "boolean"},
30-
"read-only": {"type": "boolean"},
31-
},
32-
},
33-
}
34-
3517

3618
class StopCharmError(Exception):
3719
"""Exception raised when a method needs to finish the execution of the charm code."""
@@ -40,6 +22,22 @@ def __init__(self, status: ops.StatusBase):
4022
self.status = status
4123

4224

25+
@dataclass
26+
class CharmConfig:
27+
"""Configuration for the charm."""
28+
29+
mountpoint: str
30+
"""Location to mount the filesystem on the machine."""
31+
noexec: bool
32+
"""Block execution of binaries on the filesystem."""
33+
nosuid: bool
34+
"""Do not honour suid and sgid bits on the filesystem."""
35+
nodev: bool
36+
"""Blocking interpretation of character and/or block"""
37+
read_only: bool
38+
"""Mount filesystem as read-only."""
39+
40+
4341
# Trying to use a delta charm (one method per event) proved to be a bit unwieldy, since
4442
# we would have to handle multiple updates at once:
4543
# - mount requests
@@ -51,7 +49,7 @@ def __init__(self, status: ops.StatusBase):
5149
# mount requests.
5250
#
5351
# A holistic charm (one method for all events) was a lot easier to deal with,
54-
# simplifying the code to handle all the multiple relations.
52+
# simplifying the code to handle all the events.
5553
class FilesystemClientCharm(ops.CharmBase):
5654
"""Charm the application."""
5755

@@ -66,71 +64,66 @@ def __init__(self, framework: ops.Framework):
6664
framework.observe(self._filesystems.on.mount_filesystem, self._handle_event)
6765
framework.observe(self._filesystems.on.umount_filesystem, self._handle_event)
6866

69-
def _handle_event(self, event: ops.EventBase) -> None: # noqa: C901
67+
def _handle_event(self, event: ops.EventBase) -> None:
68+
"""Handle a Juju event."""
7069
try:
7170
self.unit.status = ops.MaintenanceStatus("Updating status.")
7271

72+
# CephFS is not supported on LXD containers.
73+
if not self._mounts_manager.supported():
74+
self.unit.status = ops.BlockedStatus("Cannot mount filesystems on LXD containers.")
75+
return
76+
7377
self._ensure_installed()
7478
config = self._get_config()
7579
self._mount_filesystems(config)
7680
except StopCharmError as e:
7781
# This was the cleanest way to ensure the inner methods can still return prematurely
7882
# when an error occurs.
79-
self.app.status = e.status
83+
self.unit.status = e.status
8084
return
8185

82-
self.unit.status = ops.ActiveStatus("Mounted filesystems.")
86+
self.unit.status = ops.ActiveStatus(f"Mounted filesystem at `{config.mountpoint}`.")
8387

8488
def _ensure_installed(self):
8589
"""Ensure the required packages are installed into the unit."""
8690
if not self._mounts_manager.installed:
8791
self.unit.status = ops.MaintenanceStatus("Installing required packages.")
8892
self._mounts_manager.install()
8993

90-
def _get_config(self) -> dict[str, dict[str, str | bool]]:
94+
def _get_config(self) -> CharmConfig:
9195
"""Get and validate the configuration of the charm."""
92-
try:
93-
config = json.loads(str(self.config.get("mounts", "")))
94-
validate(config, CONFIG_SCHEMA)
95-
config: dict[str, dict[str, str | bool]] = config
96-
for fs, opts in config.items():
97-
for opt in ["noexec", "nosuid", "nodev", "read-only"]:
98-
opts[opt] = opts.get(opt, False)
99-
return config
100-
except (json.JSONDecodeError, ValidationError) as e:
96+
if not (mountpoint := self.config.get("mountpoint")):
97+
raise StopCharmError(ops.BlockedStatus("Missing `mountpoint` in config."))
98+
99+
return CharmConfig(
100+
mountpoint=str(mountpoint),
101+
noexec=bool(self.config.get("noexec")),
102+
nosuid=bool(self.config.get("nosuid")),
103+
nodev=bool(self.config.get("nodev")),
104+
read_only=bool(self.config.get("read-only")),
105+
)
106+
107+
def _mount_filesystems(self, config: CharmConfig):
108+
"""Mount the filesystem for the charm."""
109+
endpoints = self._filesystems.endpoints
110+
if not endpoints:
101111
raise StopCharmError(
102-
ops.BlockedStatus(f"invalid configuration for option `mounts`. reason:\n{e}")
112+
ops.BlockedStatus("Waiting for an integration with a filesystem provider.")
103113
)
104114

105-
def _mount_filesystems(self, config: dict[str, dict[str, str | bool]]):
106-
"""Mount all available filesystems for the charm."""
107-
endpoints = self._filesystems.endpoints
108-
for fs_type, count in Counter(
109-
[endpoint.info.filesystem_type() for endpoint in endpoints]
110-
).items():
111-
if count > 1:
112-
raise StopCharmError(
113-
ops.BlockedStatus(f"Too many relations for mount type `{fs_type}`.")
114-
)
115+
# This is limited to 1 relation.
116+
endpoint = endpoints[0]
115117

116-
self.unit.status = ops.MaintenanceStatus("Ensuring filesystems are mounted.")
118+
self.unit.status = ops.MaintenanceStatus("Mounting filesystem.")
117119

118120
with self._mounts_manager.mounts() as mounts:
119-
for endpoint in endpoints:
120-
fs_type = endpoint.info.filesystem_type()
121-
if not (options := config.get(fs_type)):
122-
raise StopCharmError(
123-
ops.BlockedStatus(f"Missing configuration for mount type `{fs_type}`.")
124-
)
125-
126-
mountpoint = str(options["mountpoint"])
127-
128-
opts = []
129-
opts.append("noexec" if options.get("noexec") else "exec")
130-
opts.append("nosuid" if options.get("nosuid") else "suid")
131-
opts.append("nodev" if options.get("nodev") else "dev")
132-
opts.append("ro" if options.get("read-only") else "rw")
133-
mounts.add(info=endpoint.info, mountpoint=mountpoint, options=opts)
121+
opts = []
122+
opts.append("noexec" if config.noexec else "exec")
123+
opts.append("nosuid" if config.nosuid else "suid")
124+
opts.append("nodev" if config.nodev else "dev")
125+
opts.append("ro" if config.read_only else "rw")
126+
mounts.add(info=endpoint.info, mountpoint=config.mountpoint, options=opts)
134127

135128

136129
if __name__ == "__main__": # pragma: nocover

src/utils/manager.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,15 @@ def install(self):
134134
for pkg in self._packages:
135135
pkg.ensure(apt.PackageState.Present)
136136
except (apt.PackageError, apt.PackageNotFoundError) as e:
137-
_logger.error(
138-
f"failed to change the state of the required packages. reason:\n{e.message}"
139-
)
137+
_logger.error("Failed to change the state of the required packages.", exc_info=e)
140138
raise Error(e.message)
141139

142140
try:
143141
self._master_file.touch(mode=0o600)
144142
self._autofs_file.touch(mode=0o600)
145143
self._master_file.write_text(f"/- {self._autofs_file}")
146144
except IOError as e:
147-
_logger.error(f"failed to create the required autofs files. reason:\n{e}")
145+
_logger.error("Failed to create the required autofs files.", exc_info=e)
148146
raise Error("failed to create the required autofs files")
149147

150148
def supported(self) -> bool:
@@ -159,7 +157,7 @@ def supported(self) -> bool:
159157
else:
160158
return True
161159
except subprocess.CalledProcessError:
162-
_logger.warning("Could not detect execution in virtualized environment")
160+
_logger.warning("Could not detect execution in virtualized environment.")
163161
return True
164162

165163
@contextlib.contextmanager
@@ -195,9 +193,7 @@ def mounts(self, force_mount=False) -> Generator[Mounts, None, None]:
195193
self._autofs_file.write_text(new_autofs)
196194
systemd.service_reload("autofs", restart_on_failure=True)
197195
except systemd.SystemdError as e:
198-
_logger.error(f"failed to mount filesystems. reason:\n{e}")
199-
if "Operation not permitted" in str(e) and not self.supported():
200-
raise Error("mounting shares not supported on LXD containers")
196+
_logger.error("Failed to mount filesystems.", exc_info=e)
201197
raise Error("failed to mount filesystems")
202198

203199

tests/integration/server/lib/charms/filesystem_client/v0/filesystem_info.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -583,16 +583,14 @@ class FilesystemRequires(_BaseInterface):
583583
def __init__(self, charm: CharmBase, relation_name: str) -> None:
584584
super().__init__(charm, relation_name)
585585
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed)
586-
self.framework.observe(
587-
charm.on[relation_name].relation_departed, self._on_relation_departed
588-
)
586+
self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_broken)
589587

590588
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
591589
"""Handle when the databag between client and server has been updated."""
592590
_logger.debug("Emitting `MountShare` event from `RelationChanged` hook")
593591
self.on.mount_filesystem.emit(event.relation, app=event.app, unit=event.unit)
594592

595-
def _on_relation_departed(self, event: RelationDepartedEvent) -> None:
593+
def _on_relation_broken(self, event: RelationDepartedEvent) -> None:
596594
"""Handle when server departs integration."""
597595
_logger.debug("Emitting `UmountShare` event from `RelationDeparted` hook")
598596
self.on.umount_filesystem.emit(event.relation, app=event.app, unit=event.unit)

0 commit comments

Comments
 (0)