Making charms more SOLID

+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 Tasks, 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.

1 Like