Skip to content

Commit 56798cb

Browse files
committed
feat: rework client to only allow a single provider
1 parent c24f411 commit 56798cb

File tree

9 files changed

+220
-275
lines changed

9 files changed

+220
-275
lines changed

.github/workflows/ci.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ on:
1919
branches:
2020
- main
2121

22+
concurrency:
23+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
24+
cancel-in-progress: true
25+
2226
jobs:
2327
inclusive-naming-check:
2428
name: Inclusive naming check

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 honor 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

+4-8
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def _on_start(self, event: ops.StartEvent) -> None:
100100
from abc import ABC, abstractmethod
101101
from dataclasses import dataclass
102102
from ipaddress import AddressValueError, IPv6Address
103-
from typing import List, Optional, TypeVar, Self
103+
from typing import List, Optional, TypeVar
104104
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunsplit
105105

106106
import ops
@@ -354,9 +354,7 @@ def from_uri(cls, uri: str, _model: Model) -> "NfsInfo":
354354
info = _UriData.from_uri(uri)
355355

356356
if info.scheme != cls.filesystem_type():
357-
raise ParseUriError(
358-
"could not parse uri with incompatible scheme into `NfsInfo`"
359-
)
357+
raise ParseUriError("could not parse uri with incompatible scheme into `NfsInfo`")
360358

361359
path = info.path
362360

@@ -582,16 +580,14 @@ class FilesystemRequires(_BaseInterface):
582580
def __init__(self, charm: CharmBase, relation_name: str) -> None:
583581
super().__init__(charm, relation_name)
584582
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed)
585-
self.framework.observe(
586-
charm.on[relation_name].relation_departed, self._on_relation_departed
587-
)
583+
self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_broken)
588584

589585
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
590586
"""Handle when the databag between client and server has been updated."""
591587
_logger.debug("emitting `MountFilesystem` event from `RelationChanged` hook")
592588
self.on.mount_filesystem.emit(event.relation, app=event.app, unit=event.unit)
593589

594-
def _on_relation_departed(self, event: RelationDepartedEvent) -> None:
590+
def _on_relation_broken(self, event: RelationDepartedEvent) -> None:
595591
"""Handle when server departs integration."""
596592
_logger.debug("emitting `UmountFilesystem` event from `RelationDeparted` hook")
597593
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

+57-64
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,40 @@
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

17-
logger = logging.getLogger(__name__)
18-
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-
}
15+
_logger = logging.getLogger(__name__)
3416

3517

3618
class StopCharmError(Exception):
3719
"""Exception raised when a method needs to finish the execution of the charm code."""
3820

39-
def __init__(self, status: ops.StatusBase):
21+
def __init__(self, status: ops.StatusBase) -> None:
4022
self.status = status
4123

4224

25+
@dataclass(frozen=True)
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 honor suid and sgid bits on the filesystem."""
35+
nodev: bool
36+
"""Blocking interpretation of character and/or block devices on the filesystem."""
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,11 +49,11 @@ 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

58-
def __init__(self, framework: ops.Framework):
56+
def __init__(self, framework: ops.Framework) -> None:
5957
super().__init__(framework)
6058

6159
self._filesystems = FilesystemRequires(self, "filesystem")
@@ -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

84-
def _ensure_installed(self):
88+
def _ensure_installed(self) -> None:
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) -> None:
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

0 commit comments

Comments
 (0)