Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
58 changes: 58 additions & 0 deletions tests/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down