Repository: canonical/cloudflared-operator
Pull request #9: Add a charm config to select the charmed-cloudflared snap channel
Author: login: weiiwang01
Labels: name: trivial
name: Libraries: Out of sync
Created: 2025-03-13T12:52:06Z | Merged: 2025-03-14T04:05:35Z

Applicable spec: <link>

### Overview

Add a charm config `charmed-cloudflared-snap-channel` to select the snap channel for the installed `charmed-cloudflared`.
Also, copy `ca-certificates.crt` into the snap's directory. This is not absolute necessary but can suppress some warnings from cloudflared.

### Rationale

<!-- The reason the change is needed -->

### Juju Events Changes

<!-- Any changes to the juju events being observed (newly added, significantly modified or deleted) -->

### Module Changes

<!-- Any high level changes to modules and why (Service, Observer, helper) -->

### Library Changes

<!-- Any changes to charm libraries -->

### Checklist

- [x] The [charm style guide](https://juju.is/docs/sdk/styleguide) was applied
- [x] The [contributing guide](https://github.com/canonical/is-charms-contributing-guide) was applied
- [x] The changes are compliant with [ISD054 - Managing Charm Complexity](https://discourse.charmhub.io/t/specification-isd014-managing-charm-complexity/11619)
- [x] The documentation is generated using `src-docs`
- [x] The documentation for charmhub is updated
- [x] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`)

<!-- Explanation for any unchecked items above -->



Diff:
diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml
index 1a50fc2..98ebc3c 100644
--- a/.github/workflows/integration_test.yaml
+++ b/.github/workflows/integration_test.yaml
@@ -5,7 +5,7 @@ on:
 
 jobs:
   integration-tests:
-    uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@secret-env
+    uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main
     secrets: inherit
     with:
       charmcraft-channel: latest/edge
diff --git a/charmcraft.yaml b/charmcraft.yaml
index c12fa23..0fa1ea2 100644
--- a/charmcraft.yaml
+++ b/charmcraft.yaml
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 # This file configures Charmcraft.
 # See https://juju.is/docs/sdk/charmcraft-config for guidance.
@@ -29,7 +29,11 @@ config:
         You can create this juju secret by using the following command:
         `juju add-secret my-token tunnel-token=<cloudflared-tunnel-token> && juju grant-secret my-token cloudflared`
       type: secret
-
+    charmed-cloudflared-snap-channel:
+      type: string
+      default: latest/stable
+      description: >-
+        Select the channel for the charmed-cloudflared snap used by the charm.
 requires:
   cloudflared-route:
     interface: cloudflared-route
diff --git a/generate-src-docs.sh b/generate-src-docs.sh
index d13066a..4e96bc2 100644
--- a/generate-src-docs.sh
+++ b/generate-src-docs.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 lazydocs --no-watermark --output-path src-docs src/*
diff --git a/src/charm.py b/src/charm.py
index 152fda3..a032080 100755
--- a/src/charm.py
+++ b/src/charm.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 # Learn more at: https://juju.is/docs/sdk
@@ -9,6 +9,7 @@
 
 import logging
 import pathlib
+import re
 import subprocess  # nosec
 import typing
 
@@ -87,6 +88,7 @@ def _on_stop(self, _: ops.EventBase) -> None:
     def _reconcile(self, _: ops.EventBase) -> None:
         """Handle changed configuration."""
         try:
+            snap_channel = self._get_charmed_cloudflared_snap_channel()
             metrics_ports = self._get_instance_metrics_ports()
             tunnel_specs = self._get_instance_tunnel_specs()
         except InvalidConfig as exc:
@@ -104,16 +106,25 @@ def _reconcile(self, _: ops.EventBase) -> None:
         for install_instance in required_snap_instances - installed_charmed_cloudflared:
             logger.info("installing charmed-cloudflared instance: %s", install_instance)
             # snap charm library doesn't support parallel instances
-            subprocess.check_call(  # nosec
+            self._subprocess_run(
                 [
                     "snap",
                     "install",
-                    CHARMED_CLOUDFLARED_SNAP_NAME,
+                    f"--channel={snap_channel}",
                     install_instance,
                 ]
             )
         for instance, tunnel_spec in tunnel_specs.items():
+            self._subprocess_run(
+                [
+                    "snap",
+                    "refresh",
+                    f"--channel={snap_channel}",
+                    instance,
+                ]
+            )
             charmed_cloudflared = snap.SnapCache()[instance]
+            self._update_ca_certificate_crt(instance)
             self._update_cloudflared_resolv_conf(instance, tunnel_spec.nameserver)
             config = {
                 "tunnel-token": tunnel_spec.tunnel_token,
@@ -128,6 +139,51 @@ def _reconcile(self, _: ops.EventBase) -> None:
             charmed_cloudflared.start(enable=True)
         self.unit.status = ops.ActiveStatus()
 
+    def _subprocess_run(self, cmd: list[str]) -> None:
+        """Run a subprocess command.
+
+        Raises:
+            CalledProcessError: subprocess run failed.
+        """
+        try:
+            subprocess.check_call(cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE)  # nosec
+        except subprocess.CalledProcessError as exc:
+            logger.error(
+                "subprocess command '%s' returned non-zero exit code: %s\noutput: %s",
+                " ".join(cmd),
+                exc.returncode,
+                exc.stdout,
+            )
+            raise
+
+    def _get_charmed_cloudflared_snap_channel(self) -> str:
+        """Get charmed-cloudflared-snap-channel charm configuration.
+
+        Returns:
+            charmed-cloudflared-snap-channel configuration value.
+
+        Raises:
+            InvalidConfig: charmed-cloudflared-snap-channel is not valid
+        """
+        channel = typing.cast(str, self.config["charmed-cloudflared-snap-channel"])
+        components = channel.split("/")
+        if len(components) > 3:
+            raise InvalidConfig("invalid charmed-cloudflared-snap-channel configuration")
+        track, risk, branch = None, None, None
+        if len(components) == 1:
+            risk = components[0]
+        elif len(components) == 2:
+            track, risk = components
+        else:
+            track, risk, branch = components
+        if track and not re.match("^[0-9a-z.-]+$", track):
+            raise InvalidConfig("invalid charmed-cloudflared-snap-channel configuration")
+        if not risk or risk not in {"stable", "candidate", "beta", "edge"}:
+            raise InvalidConfig("invalid charmed-cloudflared-snap-channel configuration")
+        if branch and not re.match("^[0-9a-z.-]+$", branch):
+            raise InvalidConfig("invalid charmed-cloudflared-snap-channel configuration")
+        return channel
+
     def _get_installed_cloudflared_snaps(self) -> set[str]:
         """Get installed charmed-cloudflared snap instances.
 
@@ -141,6 +197,27 @@ def _get_installed_cloudflared_snaps(self) -> set[str]:
                 installed_charmed_cloudflared.add(installed_snap["name"])
         return installed_charmed_cloudflared
 
+    def _update_ca_certificate_crt(self, name: str) -> None:
+        """Update the ca-certificates.crt file for the specified charmed-cloudflared snap instance.
+
+        Args:
+            name: The name of the charmed-cloudflared snap instance.
+        """
+        ca_certificates = pathlib.Path("/etc/ssl/certs/ca-certificates.crt")
+        ca_certificates_content = ca_certificates.read_bytes()
+        snap_ca_certificates = pathlib.Path(
+            f"/var/snap/{name}/current/etc/ssl/certs/ca-certificates.crt"
+        )
+        if (
+            not snap_ca_certificates.exists()
+            or ca_certificates_content != snap_ca_certificates.read_bytes()
+        ):
+            snap_ca_certificates.parent.mkdir(parents=True, exist_ok=True)
+            snap_ca_certificates.write_bytes(ca_certificates_content)
+            snap_ca_certificates.chmod(0o444)  # ca-certificates.crt
+            snap_ca_certificates.parent.chmod(0o555)  # certs/
+            snap_ca_certificates.parent.parent.chmod(0o555)  # ssl/
+
     def _update_cloudflared_resolv_conf(self, name: str, nameserver: str | None) -> None:
         """Update the resolv.conf file for the specified charmed-cloudflared snap instance.
 
@@ -158,6 +235,7 @@ def _update_cloudflared_resolv_conf(self, name: str, nameserver: str | None) ->
             or current_resolv_conf.read_text(encoding="utf-8") != resolv_conf
         ):
             current_resolv_conf.write_text(resolv_conf, encoding="utf-8")
+            current_resolv_conf.chmod(0o444)
 
     def _get_instance_tunnel_specs(self) -> dict[str, CloudflaredSpec]:
         """Get cloudflared configurations for all charmed-cloudflared snap instances.
diff --git a/tests/conftest.py b/tests/conftest.py
index 0462fa4..f8993b7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Fixtures for charm tests."""
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
index e3979c0..dddb292 100644
--- a/tests/integration/__init__.py
+++ b/tests/integration/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 476afc4..a7575d0 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 # pylint: disable=protected-access
diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py
index 8438f22..3a5ddb0 100644
--- a/tests/integration/test_charm.py
+++ b/tests/integration/test_charm.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 # pylint: disable=protected-access,too-many-arguments,too-many-positional-arguments
@@ -92,6 +92,21 @@ async def test_cloudflared_route_integration(
     wait_for_tunnel_healthy(cloudflare_api, tunnel_token_2)
 
 
+async def test_update_snap_channel(ops_test, model, cloudflared_charm):
+    """
+    arrange: deploy the cloudflared charm.
+    act: update the charmed-cloudflared-snap-channel charm configuration.
+    assume: cloudflared charm should refresh all charmed-cloudflared snap instances.
+    """
+    await cloudflared_charm.set_config({"charmed-cloudflared-snap-channel": "latest/edge"})
+    await model.wait_for_idle()
+    _, snap_list, _ = await ops_test.juju("exec", "--unit", "chrony/0", "--", "snap", "list")
+    assert "charmed-cloudflared_" in snap_list
+    for line in snap_list.splitlines():
+        if "charmed-cloudflared_" in line:
+            assert "latest/edge" in line
+
+
 async def test_nameserver(
     ops_test,
     model,
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index e3979c0..dddb292 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1,2 +1,2 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 17c8c8c..cc0988a 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Unit test fixtures."""
diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
index 67ec4e3..56920d5 100644
--- a/tests/unit/test_charm.py
+++ b/tests/unit/test_charm.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 # pylint: disable=protected-access,too-many-arguments,too-many-positional-arguments
@@ -9,6 +9,7 @@
 
 import ops
 import ops.testing
+import pytest
 
 import src.charm
 from charm import CloudflaredCharm
@@ -93,3 +94,38 @@ def test_invalid_integration_data():
         "received invalid data from cloudflared-route integration: "
         "secret doesn't have 'tunnel-token' field"
     )
+
+
+@pytest.mark.parametrize(
+    "channel,is_valid",
+    [
+        ("latest/edge", True),
+        ("edge", True),
+        ("latest/edge/test", True),
+        ("latest", False),
+        ("$/edge", False),
+        ("latest/test", False),
+        ("latest/edge/test/1", False),
+        ("", False),
+    ],
+)
+def test_charmed_cloudflared_snap_channel_config(channel: str, is_valid: bool):
+    """
+    arrange: create a scenario with different charmed-cloudflared-snap-channel.
+    act: run the config-changed event.
+    assert: cloudflared charm should enter blocked state when the config is invalid.
+    """
+    context = ops.testing.Context(CloudflaredCharm)
+
+    out = context.run(
+        context.on.config_changed(),
+        ops.testing.State(
+            config={"charmed-cloudflared-snap-channel": channel},
+        ),
+    )
+    if is_valid:
+        assert out.unit_status.message != (
+            "invalid charmed-cloudflared-snap-channel configuration"
+        )
+    else:
+        assert out.unit_status.message == "invalid charmed-cloudflared-snap-channel configuration"
diff --git a/tox.ini b/tox.ini
index 5f812f9..91a5078 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,4 +1,4 @@
-# Copyright 2024 Canonical Ltd.
+# Copyright 2025 Canonical Ltd.
 # See LICENSE file for licensing details.
 
 [tox]


Reviews:
user: login: github-actions[bot]
body: 
<!-- license-eye hidden identification -->
[license-eye](https://github.com/apache/skywalking-eyes/tree/main/cmd/license-eye) has checked 39 files.
| Valid | Invalid | Ignored | Fixed |
| --- | --- | --- | --- |
| 0 | 11 | 28 | 0 |
<details>
  <summary>Click to see the invalid file list</summary>

  - charmcraft.yaml
- generate-src-docs.sh
- src/charm.py
- tests/conftest.py
- tests/integration/__init__.py
- tests/integration/conftest.py
- tests/integration/test_charm.py
- tests/unit/__init__.py
- tests/unit/conftest.py
- tests/unit/test_charm.py
- tox.ini
</details>

<details>
	<summary>Use this command to fix any missing license headers</summary>

	```bash
docker run -it --rm -v $(pwd):/github/workspace apache/skywalking-eyes header fix
```
</details>

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T12:54:56Z
user: login: amandahla
state: APPROVED
author_association: NONE
submitted_at: 2025-03-13T13:52:06Z
user: login: amandahla
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-13T13:52:41Z
user: login: javierdelapuente
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-13T15:18:05Z
user: login: weiiwang01
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T16:10:08Z

Review comments:
diff_hunk: @@ -101,19 +101,29 @@ def _reconcile(self, _: ops.EventBase) -> None:
         for remove_instance in installed_charmed_cloudflared - required_snap_instances:
             logger.info("removing charmed-cloudflared instance: %s", remove_instance)
             snap.remove(remove_instance)
+        snap_channel = self.config["charmed-cloudflared-snap-channel"]
path: src/charm.py
user: login: amandahla
body: minor/not blocker: should the content be validated?
created_at: 2025-03-13T13:52:40Z
author_association: NONE
diff_hunk: @@ -101,19 +101,29 @@ def _reconcile(self, _: ops.EventBase) -> None:
         for remove_instance in installed_charmed_cloudflared - required_snap_instances:
             logger.info("removing charmed-cloudflared instance: %s", remove_instance)
             snap.remove(remove_instance)
+        snap_channel = self.config["charmed-cloudflared-snap-channel"]
path: src/charm.py
user: login: weiiwang01
body: Yes, I think that's a great idea, added, thanks!
created_at: 2025-03-13T16:10:08Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-13T16:35:14Z
body: ## Test results for commit bb82a461574d83aa912f2cf7d04b7811b614c008

Test coverage for bb82a461574d83aa912f2cf7d04b7811b614c008

  ```
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
src/charm.py     150     61     50      4    56%   85-86, 102-140, 148-157, 184, 193-198, 206-219, 228-238, 255-265, 275-278, 297
----------------------------------------------------------
TOTAL            150     61     50      4    56%
```
  Static code analysis report

  ```
Run started:2025-03-13 16:34:32.007914

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 774
	Total lines skipped (#nosec): 4
	Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
Files skipped (0):
```
author_association: CONTRIBUTOR