+1 to everything @jose. completely agree that many charms break the SOLID principles, and I don’t fault people for it. Although its possible to write SOLID charms like you show, imo the framework (inadvertently, I think) nudges you away from that.
Where this really gets costly imo is the points you mention and also code reuse. Having these non-SOLID charms makes it much harder to see and extract the shared logic into reusable packages.
I (/Kubeflow team) went on a deep dive into this back in 2023. This post on reducing duplication proposed something really similar to your Task
/TaskFactory
concept. We have a common reconciler (CharmReconciler
, kinda equivalent to the TaskFactory
here) and then wrap each bit of charm logic in a separate Component
(equivalent to your Task
s, but with a bit more API). imo, it leads to really easy to read charm code, but also nicely separated responsibilities:
example from kubeflow-volumes
class KubeflowVolumesOperator(CharmBase):
"""Charm for the Kubeflow Volumes Web App.
https://github.com/canonical/kubeflow-volumes-operator
"""
def __init__(self, *args):
super().__init__(*args)
# add links in kubeflow-dashboard sidebar
self.kubeflow_dashboard_sidebar = KubeflowDashboardLinksRequirer(
charm=self,
relation_name="dashboard-links",
dashboard_links=DASHBOARD_LINKS,
)
# expose web app's port
http_port = ServicePort(int(self.model.config["port"]), name="http")
self.service_patcher = KubernetesServicePatch(
self, [http_port], service_name=f"{self.model.app.name}"
)
# Charm logic
self.charm_reconciler = CharmReconciler(self)
self.leadership_gate = self.charm_reconciler.add(
component=LeadershipGateComponent(
charm=self,
name="leadership-gate",
),
depends_on=[],
)
self.kubernetes_resources = self.charm_reconciler.add(
component=KubernetesComponent(
charm=self,
name="kubernetes:auth",
resource_templates=K8S_RESOURCE_FILES,
krh_resource_types={ClusterRole, ClusterRoleBinding, ServiceAccount},
krh_labels=create_charm_default_labels(
self.app.name, self.model.name, scope="auth"
),
context_callable=lambda: {"app_name": self.app.name, "namespace": self.model.name},
lightkube_client=lightkube.Client(),
),
depends_on=[self.leadership_gate],
)
self.ingress_relation = self.charm_reconciler.add(
component=SdiRelationBroadcasterComponent(
charm=self,
name="relation:ingress",
relation_name="ingress",
data_to_send={
"prefix": "/volumes",
"rewrite": "/",
"service": self.model.app.name,
"port": int(self.model.config["port"]),
},
),
depends_on=[self.leadership_gate],
)
self.kubeflow_volumes_container = self.charm_reconciler.add(
component=KubeflowVolumesPebbleService(
charm=self,
name="container:kubeflow-volumes",
container_name="kubeflow-volumes",
service_name="kubeflow-volumes",
files_to_push=[
ContainerFileTemplate(
source_template_path=CONFIG_YAML_TEMPLATE_FILE,
destination_path=CONFIG_YAML_DESTINATION_PATH,
),
],
inputs_getter=lambda: KubeflowVolumesInputs(
APP_SECURE_COOKIES=self.model.config["secure-cookies"],
BACKEND_MODE=self.model.config["backend-mode"],
VOLUME_VIEWER_IMAGE=self.model.config["volume-viewer-image"],
),
),
depends_on=[
self.leadership_gate,
self.kubernetes_resources,
],
)
self.charm_reconciler.install_default_event_handlers()
self._logging = LogForwarder(charm=self)
There’s several (maybe 10 ish?) kubeflow charms that have used this common CharmReconciler since late 2023. The Sunbeam charms do things much the same way.