From 2f56dc88e0cf0e755d1db96ea52964e9a85d4bcd Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Fri, 10 Jul 2026 13:32:52 +0800 Subject: [PATCH 1/9] Add juju4 support and arm64 version of the charms --- artifacts.yaml | 4 ++++ .../charmcraft.yaml | 1 + cloudflared-operator/charmcraft.yaml | 1 + concierge-juju4.yaml | 13 +++++++++++++ spread.yaml | 18 +++++++++++++++--- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 concierge-juju4.yaml diff --git a/artifacts.yaml b/artifacts.yaml index 4127b39..14cb5b7 100644 --- a/artifacts.yaml +++ b/artifacts.yaml @@ -9,12 +9,16 @@ charms: resources: {} platforms: - arch: amd64 + - arch: arm64 + runner: [ubuntu-24.04-arm] channel: latest/edge - name: cloudflare-configurator charmcraft-yaml: cloudflare-configurator-operator/charmcraft.yaml resources: {} platforms: - arch: amd64 + - arch: arm64 + runner: [ubuntu-24.04-arm] channel: latest/edge snaps: - name: charmed-cloudflared diff --git a/cloudflare-configurator-operator/charmcraft.yaml b/cloudflare-configurator-operator/charmcraft.yaml index 5beb5aa..d737dc1 100644 --- a/cloudflare-configurator-operator/charmcraft.yaml +++ b/cloudflare-configurator-operator/charmcraft.yaml @@ -49,6 +49,7 @@ base: ubuntu@24.04 build-base: ubuntu@24.04 platforms: amd64: + arm64: parts: charm: source: . diff --git a/cloudflared-operator/charmcraft.yaml b/cloudflared-operator/charmcraft.yaml index 44b0488..c57a17b 100644 --- a/cloudflared-operator/charmcraft.yaml +++ b/cloudflared-operator/charmcraft.yaml @@ -50,6 +50,7 @@ base: ubuntu@24.04 build-base: ubuntu@24.04 platforms: amd64: + arm64: parts: charm: source: . diff --git a/concierge-juju4.yaml b/concierge-juju4.yaml new file mode 100644 index 0000000..7fce028 --- /dev/null +++ b/concierge-juju4.yaml @@ -0,0 +1,13 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +juju: + channel: 4.0/stable + model-defaults: + test-mode: "true" + automatically-retry-hooks: "false" + +providers: + lxd: + enable: true + bootstrap: true diff --git a/spread.yaml b/spread.yaml index 0b8b4b4..4902ffd 100644 --- a/spread.yaml +++ b/spread.yaml @@ -9,9 +9,14 @@ backends: type: integration-test systems: - ubuntu-24.04: + - ubuntu-24.04-arm64: + runner: [ubuntu-24.04-arm] + arch: arm64 environment: OPCLI_GIT_REF: '$(HOST: echo "${OPCLI_GIT_REF:-main}")' - CONCIERGE: concierge.yaml + CONCIERGE: '$(HOST: echo "${CONCIERGE:-concierge.yaml}")' + CONCIERGE/cloudflared_juju4: concierge-juju4.yaml + CONCIERGE/configurator_juju4: concierge-juju4.yaml exclude: - .git - .tox @@ -21,10 +26,13 @@ integration-suites: cloudflared-operator/tests/integration/: summary: cloudflared charm integration tests working-dir: cloudflared-operator/ + auto-discover: false backends: - integration-test - systems: [ubuntu-24.04] + systems: [ubuntu-24.04, ubuntu-24.04-arm64] environment: + MODULE/cloudflared: tests/integration/test_charm.py + MODULE/cloudflared_juju4: tests/integration/test_charm.py CLOUDFLARE_ACCOUNT_ID: '$(HOST: echo "${CLOUDFLARE_ACCOUNT_ID:-}")' CLOUDFLARE_API_TOKEN: '$(HOST: echo "${CLOUDFLARE_API_TOKEN:-}")' pytest-environment-template: | @@ -36,7 +44,11 @@ integration-suites: cloudflare-configurator-operator/tests/integration/: summary: cloudflare-configurator charm integration tests working-dir: cloudflare-configurator-operator/ + auto-discover: false backends: - integration-test - systems: [ubuntu-24.04] + systems: [ubuntu-24.04, ubuntu-24.04-arm64] + environment: + MODULE/configurator: tests/integration/test_charm.py + MODULE/configurator_juju4: tests/integration/test_charm.py pytest-arguments-template: *default-pytest-args From 2bb73cce52010274acf058deb2d65d2357ead3c3 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Fri, 10 Jul 2026 14:12:43 +0800 Subject: [PATCH 2/9] Fix spread.yaml --- spread.yaml | 13 ++++++------- tests/__init__.py | 2 -- tests/integration/__init__.py | 2 -- 3 files changed, 6 insertions(+), 11 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/integration/__init__.py diff --git a/spread.yaml b/spread.yaml index 4902ffd..519beae 100644 --- a/spread.yaml +++ b/spread.yaml @@ -14,9 +14,8 @@ backends: arch: arm64 environment: OPCLI_GIT_REF: '$(HOST: echo "${OPCLI_GIT_REF:-main}")' - CONCIERGE: '$(HOST: echo "${CONCIERGE:-concierge.yaml}")' - CONCIERGE/cloudflared_juju4: concierge-juju4.yaml - CONCIERGE/configurator_juju4: concierge-juju4.yaml + CONCIERGE/juju3: '$(HOST: echo "${CONCIERGE:-concierge.yaml}")' + CONCIERGE/juju4: concierge-juju4.yaml exclude: - .git - .tox @@ -31,8 +30,8 @@ integration-suites: - integration-test systems: [ubuntu-24.04, ubuntu-24.04-arm64] environment: - MODULE/cloudflared: tests/integration/test_charm.py - MODULE/cloudflared_juju4: tests/integration/test_charm.py + MODULE/juju3: cloudflared-operator/tests/integration/test_charm.py + MODULE/juju4: cloudflared-operator/tests/integration/test_charm.py CLOUDFLARE_ACCOUNT_ID: '$(HOST: echo "${CLOUDFLARE_ACCOUNT_ID:-}")' CLOUDFLARE_API_TOKEN: '$(HOST: echo "${CLOUDFLARE_API_TOKEN:-}")' pytest-environment-template: | @@ -49,6 +48,6 @@ integration-suites: - integration-test systems: [ubuntu-24.04, ubuntu-24.04-arm64] environment: - MODULE/configurator: tests/integration/test_charm.py - MODULE/configurator_juju4: tests/integration/test_charm.py + MODULE/juju3: cloudflare-configurator-operator/tests/integration/test_charm.py + MODULE/juju4: cloudflare-configurator-operator/tests/integration/test_charm.py pytest-arguments-template: *default-pytest-args diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index dddb292..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py deleted file mode 100644 index dddb292..0000000 --- a/tests/integration/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. From 5e26cf1e27c401e7dd295b235ef224771e1c5f88 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Fri, 10 Jul 2026 14:27:36 +0800 Subject: [PATCH 3/9] Fix spread.yaml --- spread.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spread.yaml b/spread.yaml index 519beae..32f71ad 100644 --- a/spread.yaml +++ b/spread.yaml @@ -30,8 +30,8 @@ integration-suites: - integration-test systems: [ubuntu-24.04, ubuntu-24.04-arm64] environment: - MODULE/juju3: cloudflared-operator/tests/integration/test_charm.py - MODULE/juju4: cloudflared-operator/tests/integration/test_charm.py + MODULE/juju3: tests/integration/test_charm.py + MODULE/juju4: tests/integration/test_charm.py CLOUDFLARE_ACCOUNT_ID: '$(HOST: echo "${CLOUDFLARE_ACCOUNT_ID:-}")' CLOUDFLARE_API_TOKEN: '$(HOST: echo "${CLOUDFLARE_API_TOKEN:-}")' pytest-environment-template: | @@ -48,6 +48,6 @@ integration-suites: - integration-test systems: [ubuntu-24.04, ubuntu-24.04-arm64] environment: - MODULE/juju3: cloudflare-configurator-operator/tests/integration/test_charm.py - MODULE/juju4: cloudflare-configurator-operator/tests/integration/test_charm.py + MODULE/juju3: tests/integration/test_charm.py + MODULE/juju4: tests/integration/test_charm.py pytest-arguments-template: *default-pytest-args From fc983a4fe85e8bc8bd7260d9e2ab6a646000cc75 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Fri, 10 Jul 2026 15:56:39 +0800 Subject: [PATCH 4/9] Test fix for reboot container --- .../tests/integration/test_charm.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/cloudflared-operator/tests/integration/test_charm.py b/cloudflared-operator/tests/integration/test_charm.py index c664e27..7b893b5 100644 --- a/cloudflared-operator/tests/integration/test_charm.py +++ b/cloudflared-operator/tests/integration/test_charm.py @@ -5,9 +5,9 @@ """Integration tests.""" -import contextlib import json import logging +import subprocess import time import jubilant @@ -37,15 +37,33 @@ def wait_for_tunnel_healthy(cloudflare_api, tunnel_token): def reboot_application(juju: jubilant.Juju, app: str) -> None: - """Reboot all units of an application (required for deploying in LXD containers). + """Reboot the LXD containers hosting an application. Args: juju: Jubilant juju instance. app: Application name. """ - # Rebooting terminates the exec session, which is expected. - with contextlib.suppress(jubilant.CLIError): - juju.cli("exec", "--application", app, "--", "sudo", "reboot") + status = json.loads(juju.cli("status", "--format", "json")) + machines = status.get("machines", {}) + applications = status.get("applications", {}) + + machine_ids: set[str] = set() + for unit in applications.get(app, {}).get("units", {}).values(): + if "machine" in unit: + machine_ids.add(unit["machine"]) + if not machine_ids: + for principal in applications.values(): + for unit in principal.get("units", {}).values(): + subordinates = unit.get("subordinates", {}) + if any(sub.split("/")[0] == app for sub in subordinates) and "machine" in unit: + machine_ids.add(unit["machine"]) + + for machine in machine_ids: + container = machines.get(machine, {}).get("instance-id") + if container is None: + continue + logger.info("restarting LXD container %s for %s", container, app) + subprocess.run(["lxc", "restart", container], check=True) def test_tunnel_token_config(juju, cloudflare_api, cloudflared_charm): From dd6dcec265f6c8020e85a3a24c1315de4800dacb Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Mon, 13 Jul 2026 14:33:10 +0800 Subject: [PATCH 5/9] Test fix for principle charm arch --- .../tests/integration/conftest.py | 6 ++++-- .../tests/integration/test_charm.py | 20 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cloudflared-operator/tests/integration/conftest.py b/cloudflared-operator/tests/integration/conftest.py index 19342e3..f110aaf 100644 --- a/cloudflared-operator/tests/integration/conftest.py +++ b/cloudflared-operator/tests/integration/conftest.py @@ -219,7 +219,8 @@ def cloudflared_route_provider_1_fixture(juju: jubilant.Juju, cloudflared_charm: "any-charm", app=CLOUDFLARED_ROUTE_PROVIDER_1, config={"src-overwrite": SRC_OVERWRITE}, - channel="latest/edge", + channel="latest/beta", + base="ubuntu@24.04", ) juju.integrate(f"{cloudflared_charm}:cloudflared-route", CLOUDFLARED_ROUTE_PROVIDER_1) return CLOUDFLARED_ROUTE_PROVIDER_1 @@ -232,7 +233,8 @@ def cloudflared_route_provider_2_fixture(juju: jubilant.Juju, cloudflared_charm: "any-charm", app=CLOUDFLARED_ROUTE_PROVIDER_2, config={"src-overwrite": SRC_OVERWRITE}, - channel="latest/edge", + channel="latest/beta", + base="ubuntu@24.04", ) juju.integrate(f"{cloudflared_charm}:cloudflared-route", CLOUDFLARED_ROUTE_PROVIDER_2) return CLOUDFLARED_ROUTE_PROVIDER_2 diff --git a/cloudflared-operator/tests/integration/test_charm.py b/cloudflared-operator/tests/integration/test_charm.py index 7b893b5..73b3927 100644 --- a/cloudflared-operator/tests/integration/test_charm.py +++ b/cloudflared-operator/tests/integration/test_charm.py @@ -7,7 +7,7 @@ import json import logging -import subprocess +import subprocess # nosec import time import jubilant @@ -63,7 +63,7 @@ def reboot_application(juju: jubilant.Juju, app: str) -> None: if container is None: continue logger.info("restarting LXD container %s for %s", container, app) - subprocess.run(["lxc", "restart", container], check=True) + subprocess.run(["lxc", "restart", container], check=True) # nosec def test_tunnel_token_config(juju, cloudflare_api, cloudflared_charm): @@ -72,9 +72,9 @@ def test_tunnel_token_config(juju, cloudflare_api, cloudflared_charm): act: provide the tunnel-token charm config. assume: cloudflared tunnels provided in the charm config is up and healthy """ - base_app = "chrony" - juju.deploy(base_app, channel="latest/edge", config={"sources": "ntp://ntp.ubuntu.com"}) - juju.integrate(base_app, cloudflared_charm) + base_app = "any-charm" + juju.deploy("any-charm", app=base_app, channel="latest/beta", base="ubuntu@24.04") + juju.integrate(f"{base_app}:juju-info", f"{cloudflared_charm}:juju-info") tunnel_token = cloudflare_api.create_tunnel_token() secret_uri = juju.add_secret("test-tunnel-token", {"tunnel-token": tunnel_token}) juju.grant_secret("test-tunnel-token", cloudflared_charm) @@ -128,7 +128,7 @@ def test_update_snap_channel(juju, cloudflared_charm): """ juju.config(cloudflared_charm, {"charmed-cloudflared-snap-channel": "latest/edge"}) juju.wait(jubilant.all_agents_idle, error=jubilant.any_error) - snap_list = juju.cli("exec", "--unit", "chrony/0", "--", "snap", "list") + snap_list = juju.cli("exec", "--unit", "any-charm/0", "--", "snap", "list") assert "charmed-cloudflared_" in snap_list for line in snap_list.splitlines(): if "charmed-cloudflared_" in line: @@ -178,20 +178,20 @@ def test_remove(juju, cloudflared_charm): act: remove the cloudflared charm. assume: cloudflared charm should uninstall all charmed-cloudflared snap instances. """ - snap_list = juju.cli("exec", "--unit", "chrony/0", "--", "snap", "list") + snap_list = juju.cli("exec", "--unit", "any-charm/0", "--", "snap", "list") assert "charmed-cloudflared_" in snap_list logger.info("snap list before removal: %s", snap_list) - juju.remove_relation(cloudflared_charm, "chrony") + juju.remove_relation(cloudflared_charm, "any-charm") juju.wait(lambda status: not status.apps[cloudflared_charm].units) deadline = time.time() + 300 while True: - snap_list = juju.cli("exec", "--unit", "chrony/0", "--", "snap", "list") + snap_list = juju.cli("exec", "--unit", "any-charm/0", "--", "snap", "list") if "charmed-cloudflared_" not in snap_list or time.time() > deadline: break time.sleep(5) assert "charmed-cloudflared_" not in snap_list logger.info("snap list after removal: %s", snap_list) - juju.integrate("chrony", cloudflared_charm) + juju.integrate("any-charm", cloudflared_charm) def test_secret_config_permission( From 0c055f668cdcf15f6191a790c7257cfae29150f9 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Mon, 13 Jul 2026 15:46:06 +0800 Subject: [PATCH 6/9] Test fix for any-charm --- cloudflared-operator/tests/integration/test_charm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudflared-operator/tests/integration/test_charm.py b/cloudflared-operator/tests/integration/test_charm.py index 73b3927..88670b0 100644 --- a/cloudflared-operator/tests/integration/test_charm.py +++ b/cloudflared-operator/tests/integration/test_charm.py @@ -181,7 +181,7 @@ def test_remove(juju, cloudflared_charm): snap_list = juju.cli("exec", "--unit", "any-charm/0", "--", "snap", "list") assert "charmed-cloudflared_" in snap_list logger.info("snap list before removal: %s", snap_list) - juju.remove_relation(cloudflared_charm, "any-charm") + juju.remove_relation(f"{cloudflared_charm}:juju-info", "any-charm:juju-info") juju.wait(lambda status: not status.apps[cloudflared_charm].units) deadline = time.time() + 300 while True: @@ -191,7 +191,7 @@ def test_remove(juju, cloudflared_charm): time.sleep(5) assert "charmed-cloudflared_" not in snap_list logger.info("snap list after removal: %s", snap_list) - juju.integrate("any-charm", cloudflared_charm) + juju.integrate("any-charm:juju-info", f"{cloudflared_charm}:juju-info") def test_secret_config_permission( From 35ce6720494631e234681b6d73e1c1fb388f05e1 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Mon, 13 Jul 2026 16:56:13 +0800 Subject: [PATCH 7/9] Test fix for snap removal on stop --- cloudflared-operator/src/charm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloudflared-operator/src/charm.py b/cloudflared-operator/src/charm.py index 45bef31..e3172da 100755 --- a/cloudflared-operator/src/charm.py +++ b/cloudflared-operator/src/charm.py @@ -59,11 +59,12 @@ def __init__(self, *args: typing.Any): self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.config_changed, self._reconcile) self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.stop, self._on_stop) self.framework.observe(self.on["cloudflared-route"].relation_changed, self._reconcile) self.framework.observe(self.on["cloudflared-route"].relation_departed, self._reconcile) self.framework.observe(self.on["juju-info"].relation_changed, self._reconcile) self.framework.observe(self.on["juju-info"].relation_departed, self._reconcile) - self.framework.observe(self.on.stop, self._on_stop) + self.framework.observe(self.on["juju-info"].relation_broken, self._on_stop) self._snap_client = snap.SnapClient() self._cloudflared_route = CloudflaredRouteRequirer(self) self._grafana_agent = COSAgentProvider( From 532fa30ad7c503b50f21a95527e6d1a0d35a26a3 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Mon, 13 Jul 2026 18:30:33 +0800 Subject: [PATCH 8/9] Test stop when there's no principle --- cloudflared-operator/src/charm.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cloudflared-operator/src/charm.py b/cloudflared-operator/src/charm.py index e3172da..062ab3a 100755 --- a/cloudflared-operator/src/charm.py +++ b/cloudflared-operator/src/charm.py @@ -25,6 +25,7 @@ logger = logging.getLogger(__name__) CLOUDFLARED_ROUTE_INTEGRATION_NAME = "cloudflared-route" +JUJU_INFO_INTEGRATION_NAME = "juju-info" # this is not a hardcoded password TUNNEL_TOKEN_CONFIG_NAME = "tunnel-token" # nosec CHARMED_CLOUDFLARED_SNAP_NAME = "charmed-cloudflared" @@ -87,6 +88,16 @@ def _on_stop(self, _: ops.EventBase) -> None: for instance in self._get_installed_cloudflared_snaps(): snap.remove(instance) + def _has_principal(self) -> bool: + """Check whether the charm is still related to a principal via juju-info. + + Returns: + True if an active juju-info relation exists. + """ + return any( + relation.active for relation in self.model.relations[JUJU_INFO_INTEGRATION_NAME] + ) + def _reconcile(self, _: ops.EventBase) -> None: """Handle changed configuration.""" try: @@ -98,6 +109,8 @@ def _reconcile(self, _: ops.EventBase) -> None: self.unit.status = ops.BlockedStatus(str(exc)) return required_snap_instances = set(metrics_ports.keys()) + if not self._has_principal(): + required_snap_instances = set() if not required_snap_instances: self.unit.status = ops.WaitingStatus("waiting for tunnel token") return From b272c2dde4ca13b886f0d0250d0703bf907a91e6 Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Mon, 13 Jul 2026 19:10:22 +0800 Subject: [PATCH 9/9] Change remove condition --- cloudflared-operator/tests/integration/test_charm.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cloudflared-operator/tests/integration/test_charm.py b/cloudflared-operator/tests/integration/test_charm.py index 88670b0..48a9a5d 100644 --- a/cloudflared-operator/tests/integration/test_charm.py +++ b/cloudflared-operator/tests/integration/test_charm.py @@ -182,7 +182,10 @@ def test_remove(juju, cloudflared_charm): assert "charmed-cloudflared_" in snap_list logger.info("snap list before removal: %s", snap_list) juju.remove_relation(f"{cloudflared_charm}:juju-info", "any-charm:juju-info") - juju.wait(lambda status: not status.apps[cloudflared_charm].units) + juju.wait( + lambda status: not status.apps[cloudflared_charm].units + and "juju-info" not in status.apps[cloudflared_charm].relations + ) deadline = time.time() + 300 while True: snap_list = juju.cli("exec", "--unit", "any-charm/0", "--", "snap", "list")