From 2b5960b56c8cd85e8399b434337c77dc59232280 Mon Sep 17 00:00:00 2001 From: ale8k Date: Thu, 10 Sep 2026 16:01:04 +0100 Subject: [PATCH] feat: open api ports from config reconciliation --- charmcraft.yaml | 21 ++++++++++++++++ src/charm.py | 24 +++++++++++++++++++ tests/test_charm.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/charmcraft.yaml b/charmcraft.yaml index 99a3181..b844b65 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -110,6 +110,27 @@ config: default: true description: Whether this is a standalone Juju controller type: boolean + api-port: + default: 17070 + description: | + The port on which the controller API server listens. The charm opens + this port so that clients can reach the controller API. Expose the + application (optionally with --to-cidrs) to control access. + type: int + ssh-server-port: + default: 17022 + description: | + The port on which the controller's embedded SSH server (jump host) + listens. The charm opens this port so that clients can reach the SSH + server. Expose the application (optionally with --to-cidrs) to control + access. + type: int + autocert-dns-name: + default: "" + description: | + The DNS name used for Let's Encrypt (autocert). When set, the charm + opens port 80 to handle the Let's Encrypt HTTP challenge. + type: string workload-tracing-stack-traces: default: false description: Whether stack traces should be added per workload tracing span. diff --git a/src/charm.py b/src/charm.py index 929d03d..9544ac3 100755 --- a/src/charm.py +++ b/src/charm.py @@ -24,6 +24,7 @@ InstallEvent, LeaderElectedEvent, ) +from ops import Port from ops.framework import StoredState from ops.main import main from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, Relation @@ -258,8 +259,31 @@ def _on_collect_status(self, event: CollectStatusEvent): def _on_config_changed(self, _): controller_url = self.config['controller-url'] logger.info('got a new controller-url: %r', controller_url) + self._reconcile_ports() self._update_workload_tracing_config() + def _reconcile_ports(self): + """Open the controller ports declared in charm config. + + The controller application (jujud) is the workload of this charm, so + its externally-reachable ports are a property of the charm. We use the + declarative set_ports so that any previously-opened port not in the + desired set is closed. The ports only become reachable once the + application is exposed (juju expose), which allows operators to + restrict access with --to-cidrs. + """ + desired = [ + Port('tcp', int(self.config['api-port'])), + Port('tcp', int(self.config['ssh-server-port'])), + ] + # Port 80 is only needed for the Let's Encrypt HTTP challenge, which + # only applies when an autocert DNS name is configured. + if self.config['autocert-dns-name']: + desired.append(Port('tcp', 80)) + + logger.info('reconciling controller ports: %r', desired) + self.unit.set_ports(*desired) + def _on_dashboard_relation_joined(self, event): logger.info('got a new dashboard relation: %r', event) if self.unit.is_leader(): diff --git a/tests/test_charm.py b/tests/test_charm.py index 12265ed..59afeb5 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -2654,6 +2654,64 @@ def test_dbcluster_leader_elected_reconciles_bind_addresses( mock_reload_config.assert_called_once() +@patch("controlsocket.ControlSocketClient.set_workload_tracing_config", Mock()) +@patch("controlsocket.ControlSocketClient.set_charm_tracing_config", Mock()) +@patch("controlsocket.ControlSocketClient.set_loki_endpoint", Mock()) +class TestControllerPorts(unittest.TestCase): + """Tests that the charm opens the controller ports declared in config. + + The controller ports (API, SSH server, and optionally 80 for autocert) are + a property of the charm and are opened via config. They only become + reachable once the application is exposed. + """ + + def setUp(self): + self.harness = Harness(JujuControllerCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def _opened(self): + # Return the set of (protocol, port) tuples currently opened. + return {(p.protocol, p.port) for p in self.harness.model.unit.opened_ports()} + + def test_default_ports_opened_on_config_changed(self): + # With default config, the API and SSH server ports are opened and + # port 80 is not (no autocert DNS name configured). + self.harness.charm.on.config_changed.emit() + + self.assertEqual(self._opened(), {("tcp", 17070), ("tcp", 17022)}) + + def test_custom_ports_opened(self): + # Changing the port config opens the new ports and closes the old ones + # (set_ports is declarative). + self.harness.charm.on.config_changed.emit() + self.assertIn(("tcp", 17022), self._opened()) + + self.harness.update_config({"ssh-server-port": 17099, "api-port": 17071}) + + self.assertEqual(self._opened(), {("tcp", 17071), ("tcp", 17099)}) + + def test_autocert_opens_port_80(self): + # Setting an autocert DNS name opens port 80 for the Let's Encrypt + # HTTP challenge, in addition to the API and SSH server ports. + self.harness.update_config({"autocert-dns-name": "controller.example.com"}) + + self.assertEqual( + self._opened(), + {("tcp", 17070), ("tcp", 17022), ("tcp", 80)}, + ) + + def test_autocert_unset_closes_port_80(self): + # Clearing the autocert DNS name closes port 80 again. + self.harness.update_config({"autocert-dns-name": "controller.example.com"}) + self.assertIn(("tcp", 80), self._opened()) + + self.harness.update_config({"autocert-dns-name": ""}) + + self.assertNotIn(("tcp", 80), self._opened()) + self.assertEqual(self._opened(), {("tcp", 17070), ("tcp", 17022)}) + + class mockNetwork: def __init__(self, addresses): self.ingress_addresses = [ipaddress.ip_address(addr) for addr in addresses]