diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..61d9081 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 99 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2531241 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "mod/jinja"] + path = mod/jinja + url = https://github.com/pallets/jinja +[submodule "mod/ops-interface-proxy-listen-tcp"] + path = mod/ops-interface-proxy-listen-tcp + url = https://github.com/dshcherb/ops-interface-proxy-listen-tcp +[submodule "mod/operator"] + path = mod/operator + url = https://github.com/canonical/operator diff --git a/README.md b/README.md new file mode 100644 index 0000000..0755e99 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +CockroachDB Charm +================================== + +# Overview + +This charm provides means to deploy and operate cockroachdb - a scalable, cloud-native SQL database with built-in clustering support. + +# Deployment Requirements + +The charm requires Juju 2.7.5 or above (see [LP: #1865229](https://bugs.launchpad.net/juju/+bug/1865229)). + +# Deployment + +In order to deploy CockroachDB in a single-node mode, set replication factors to 1 explicitly. + +```bash +juju deploy --config default-zone-replicas=1 --config system-data-replicas=1 +``` + +CockroachDB will use a replication factor of 3 unless explicitly specified. + +```bash +juju deploy +juju add-unit cockroachdb -n 3 +``` + +HA with an explicit amount of replicas. + +```bash +juju deploy --config default-zone-replicas=3 --config system-data-replicas=3 -n 3 +``` + +# Accessing CLI + +``` +juju ssh cockroachdb/0 +cockroach sql +``` + +# Web UI + +The web UI is accessible at `https://:8080` + +# Known Issues + +The charm uses a workaround for [LP: #1859769](https://bugs.launchpad.net/juju/+bug/1859769) for single-node deployments by saving a cluster ID in a local state before the peer relation becomes available. diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c4521c7 --- /dev/null +++ b/config.yaml @@ -0,0 +1,13 @@ +options: + version: + type: string + description: CockroachDB version to use. + default: v19.2.2 + default-zone-replicas: + type: int + description: The number of replicas to configure for the default replication zone. Using 0 means that the charm will use cluster defaults (see https://www.cockroachlabs.com/docs/stable/configure-replication-zones.html#replication-zone-variables). + default: 0 + system-data-replicas: + type: int + description: The number of replicas to configure for the internal system data. Using 0 means that the charm will use cluster defaults (see https://www.cockroachlabs.com/docs/stable/configure-replication-zones.html#replication-zone-variables) + default: 0 diff --git a/hooks/install b/hooks/install new file mode 120000 index 0000000..25b1f68 --- /dev/null +++ b/hooks/install @@ -0,0 +1 @@ +../src/charm.py \ No newline at end of file diff --git a/lib/interface_proxy_listen_tcp.py b/lib/interface_proxy_listen_tcp.py new file mode 120000 index 0000000..90c75ad --- /dev/null +++ b/lib/interface_proxy_listen_tcp.py @@ -0,0 +1 @@ +../mod/ops-interface-proxy-listen-tcp/interface_proxy_listen_tcp.py \ No newline at end of file diff --git a/lib/jinja2 b/lib/jinja2 new file mode 120000 index 0000000..38b85c5 --- /dev/null +++ b/lib/jinja2 @@ -0,0 +1 @@ +../mod/jinja/src/jinja2 \ No newline at end of file diff --git a/lib/ops b/lib/ops new file mode 120000 index 0000000..d934193 --- /dev/null +++ b/lib/ops @@ -0,0 +1 @@ +../mod/operator/ops \ No newline at end of file diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..80e2367 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,22 @@ +name: cockroachdb +summary: CockroachDB Charm +maintainers: + - github.com/dshcherb +description: CockroachDB Charm +min-juju-version: 2.7.5 +tags: + - database +provides: + db: + interface: pgsql + optional: true +peers: + cluster: + interface: cockroachdb-peer +series: + - bionic +resources: + cockroach-linux-amd64: + type: file + filename: cockroach.linux-amd64.tgz + description: An archive with a binary named "cockroach" as downloaded from https://binaries.cockroachdb.com/cockroach-.linux-amd64.tgz diff --git a/mod/jinja b/mod/jinja new file mode 160000 index 0000000..bff4893 --- /dev/null +++ b/mod/jinja @@ -0,0 +1 @@ +Subproject commit bff4893d5fb8a26ff38725609547189a897234b0 diff --git a/mod/operator b/mod/operator new file mode 160000 index 0000000..11a1849 --- /dev/null +++ b/mod/operator @@ -0,0 +1 @@ +Subproject commit 11a1849205d750e28aaa4a13938b5864659f928b diff --git a/mod/ops-interface-proxy-listen-tcp b/mod/ops-interface-proxy-listen-tcp new file mode 160000 index 0000000..63c8e77 --- /dev/null +++ b/mod/ops-interface-proxy-listen-tcp @@ -0,0 +1 @@ +Subproject commit 63c8e777ea3e774bb6e8666b3c9e9e219828b323 diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..7a649b3 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import sys +sys.path.append('lib') # noqa + +from ops.charm import CharmBase +from ops.main import main +from ops.model import ( + ActiveStatus, + BlockedStatus, + MaintenanceStatus, + WaitingStatus, +) + +from db_instance_manager import DbInstanceManager +from cluster import CockroachDbCluster + + +class CockroachDbCharm(CharmBase): + + PSQL_PORT = 26257 + HTTP_PORT = 8080 + + # A type to use for the database instance manager. The class attribute is + # used to inject a different type during unit testing. + instance_manager_cls = DbInstanceManager + + def __init__(self, framework, key): + super().__init__(framework, key) + + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.start, self._on_start) + self.framework.observe(self.on.config_changed, self._on_config_changed) + self.framework.observe(self.on.cluster_relation_changed, self._on_cluster_relation_changed) + + self.cluster = CockroachDbCluster(self, 'cluster') + self.instance_manager = self.instance_manager_cls( + self, None, self.is_single_node, self.cluster) + self.framework.observe(self.instance_manager.on.cluster_initialized, + self.cluster.on_cluster_initialized) + + self.framework.observe(self.instance_manager.on.daemon_started, self._on_daemon_started) + + def _on_install(self, event): + self.instance_manager.install() + + @property + def is_single_node(self): + """Both replication factors were set to 1 so it's a good guess that an operator wants + a 1-node deployment.""" + default_zone_rf = self.model.config['default-zone-replicas'] + system_data_rf = self.model.config['system-data-replicas'] + return default_zone_rf == 1 and system_data_rf == 1 + + def _on_start(self, event): + # If both replication factors are set to 1 and the current unit != initial cluster unit, + # don't start the process if the cluster has already been initialized. + # This configuration is not practical in real deployments (i.e. multiple units, RF=1). + initial_unit = self.cluster.initial_unit + if self.is_single_node and ( + initial_unit is not None and self.unit.name != initial_unit): + self.unit.status = BlockedStatus('Extra unit in a single-node deployment.') + return + self.instance_manager.start() + + if self.cluster.is_joined and self.cluster.is_cluster_initialized: + self.unit.status = ActiveStatus() + + def _on_cluster_relation_changed(self, event): + self.instance_manager.reconfigure() + if self.instance_manager.is_started and self.cluster.is_cluster_initialized: + self.unit.status = ActiveStatus() + + def _on_daemon_started(self, event): + if not self.cluster.is_joined and not self.is_single_node: + self.unit.status = WaitingStatus('Waiting for peer units to join.') + event.defer() + return + if self.cluster.is_cluster_initialized: + # Skip this event when some other unit has already initialized a cluster. + self.unit.status = ActiveStatus() + return + elif not self.unit.is_leader(): + self.unit.status = WaitingStatus( + 'Waiting for the leader unit to initialize a cluster.') + event.defer() + return + self.unit.status = MaintenanceStatus('Initializing the cluster.') + # Initialize the cluster if we're a leader in a multi-node deployment, otherwise it have + # already been initialized by running start-single-node. + if not self.is_single_node and self.model.unit.is_leader(): + self.instance_manager.init_db() + + self.unit.status = ActiveStatus() + + def _on_config_changed(self, event): + self.instance_manager.reconfigure() + + +if __name__ == '__main__': + main(CockroachDbCharm) diff --git a/src/cluster.py b/src/cluster.py new file mode 100644 index 0000000..27c12c2 --- /dev/null +++ b/src/cluster.py @@ -0,0 +1,65 @@ +from ops.framework import Object, StoredState + + +class CockroachDbCluster(Object): + + stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self._relation_name = relation_name + self.stored.set_default(cluster_id=None) + + @property + def _relation(self): + return self.framework.model.get_relation(self._relation_name) + + @property + def is_single(self): + return len(self.framework.model.relations[self._relation_name]) == 1 + + @property + def is_joined(self): + return self._relation is not None + + def on_cluster_initialized(self, event): + if not self.framework.model.unit.is_leader(): + raise RuntimeError('The initial unit of a cluster must also be a leader.') + + # A workaround for LP: #1859769. + self.stored.cluster_id = event.cluster_id + if not self.is_joined: + event.defer() + return + + self._relation.data[self.model.app]['initial_unit'] = self.framework.model.unit.name + self._relation.data[self.model.app]['cluster_id'] = self.stored.cluster_id + + @property + def is_cluster_initialized(self): + """Determined by the presence of a cluster ID.""" + if self.is_joined: + return self._relation.data[self.model.app].get('cluster_id') is not None + elif self.stored.cluster_id: + return True + else: + return False + + @property + def initial_unit(self): + """Return the unit that has initialized the cluster.""" + if self.is_joined: + return self._relation.data[self.model.app].get('initial_unit') + else: + return None + + @property + def peer_addresses(self): + addresses = [] + for u in self._relation.units: + addresses.append(self._relation.data[u]['ingress-address']) + return addresses + + @property + def advertise_addr(self): + return self.model.get_binding(self._relation_name).network.ingress_address diff --git a/src/db_instance_manager.py b/src/db_instance_manager.py new file mode 100644 index 0000000..eb14437 --- /dev/null +++ b/src/db_instance_manager.py @@ -0,0 +1,193 @@ +import subprocess +import os +import re +import pwd + +from datetime import timedelta +from time import sleep + +from ops.model import ModelError +from ops.charm import CharmEvents +from ops.framework import ( + Object, + StoredState, + EventBase, + EventSource, +) + +from jinja2 import Environment, FileSystemLoader + + +class ClusterInitializedEvent(EventBase): + + def __init__(self, handle, cluster_id): + super().__init__(handle) + self.cluster_id = cluster_id + + def snapshot(self): + return self.cluster_id + + def restore(self, cluster_id): + self.cluster_id = cluster_id + + +class DaemonStartedEvent(EventBase): + """Emitted when a database daemon is started by the charm.""" + + +class DbInstanceManagerEvents(CharmEvents): + daemon_started = EventSource(DaemonStartedEvent) + cluster_initialized = EventSource(ClusterInitializedEvent) + + +class DbInstanceManager(Object): + """Responsible for managing machine state related to a database instance.""" + + on = DbInstanceManagerEvents() + + _stored = StoredState() + + COCKROACHDB_SERVICE = 'cockroachdb.service' + SYSTEMD_SERVICE_FILE = f'/etc/systemd/system/{COCKROACHDB_SERVICE}' + WORKING_DIRECTORY = '/var/lib/cockroach' + COCKROACH_INSTALL_DIR = '/usr/local/bin' + COCKROACH_BINARY_PATH = f'{COCKROACH_INSTALL_DIR}/cockroach' + COCKROACH_USERNAME = 'cockroach' + MAX_RETRIES = 10 + RETRY_TIMEOUT = timedelta(milliseconds=125) + + def __init__(self, charm, key, is_single_node, cluster): + super().__init__(charm, key) + self._stored.set_default(is_started=False) + self._stored.set_default(is_initialized=False) + self._is_single_node = is_single_node + self._cluster = cluster + + def install(self): + self._install_binary() + self._setup_systemd_service() + + def _install_binary(self): + """Install CockroachDB from a resource or download a binary.""" + try: + resource_path = self.model.resources.fetch('cockroach-linux-amd64') + except ModelError: + resource_path = None + + if resource_path is None: + architecture = 'amd64' # hard-coded until it becomes important + version = self.model.config['version'] + cmd = (f'wget -qO- https://binaries.cockroachdb.com/' + f'cockroach-{version}.linux-{architecture}.tgz' + f'| tar -C {self.COCKROACH_INSTALL_DIR} -xvz --wildcards' + ' --strip-components 1 --no-anchored "cockroach*/cockroach"') + subprocess.check_call(cmd, shell=True) + os.chown(self.COCKROACH_BINARY_PATH, 0, 0) + else: + cmd = ['tar', '-C', self.COCKROACH_INSTALL_DIR, '-xv', '--wildcards', + '--strip-components', '1', '--no-anchored', 'cockroach*/cockroach', + '-zf', str(resource_path)] + subprocess.check_call(cmd) + + def start(self): + """Start the CockroachDB daemon. + + Starting the daemon for the first time in the single instance mode also initializes the + database on-disk state. + """ + self._run_start() + self._stored.is_started = True + if self._is_single_node and not self._stored.is_initialized: + self._stored.is_initialized = self._stored.is_initialized = True + self.on.cluster_initialized.emit(self._get_cluster_id()) + self.on.daemon_started.emit() + + def _run_start(self): + subprocess.check_call(['systemctl', 'start', f'{self.COCKROACHDB_SERVICE}']) + + def init_db(self): + if self._is_single_node: + raise RuntimeError('tried to initialize a database in a single unit mode') + elif not self.model.unit.is_leader(): + raise RuntimeError('tried to initialize a database as a minion') + self._run_init() + self.on.cluster_initialized.emit(self._get_cluster_id()) + + def _run_init(self): + subprocess.check_call([self.COCKROACH_BINARY_PATH, 'init', '--insecure']) + + def reconfigure(self): + # TODO: handle real changes here like changing the replication factors via cockroach sql + # TODO: emit daemon_started when a database is restarted. + self._setup_systemd_service() + + @property + def is_started(self): + return self._stored.is_started + + def _get_cluster_id(self): + for _ in range(self.MAX_RETRIES): + res = subprocess.run([self.COCKROACH_BINARY_PATH, 'debug', 'gossip-values', + '--insecure'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if not res.returncode: + out = res.stdout.decode('utf-8') + break + elif not re.findall(r'code = Unavailable desc = node waiting for init', + res.stderr.decode('utf-8')): + raise RuntimeError( + 'unexpected error returned while trying to obtain gossip-values') + sleep(self.RETRY_TIMEOUT.total_seconds()) + + cluster_id_regex = re.compile(r'"cluster-id": (?P[0-9a-fA-F]{8}\-[0-9a-fA-F]' + r'{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$') + for line in out.split('\n'): + m = cluster_id_regex.match(line) + if m: + return m.group('uuid') + raise RuntimeError('could not find cluster-id in the gossip-values output') + + def _setup_systemd_service(self): + if self._is_single_node: + # start-single-node will set replication factors for all zones to 1. + exec_start_line = (f'ExecStart={self.COCKROACH_BINARY_PATH} start-single-node' + ' --advertise-addr {self._cluster.advertise_addr} --insecure') + else: + peer_addresses = [self._cluster.advertise_addr] + if self._cluster.is_joined: + peer_addresses.extend(self._cluster.peer_addresses) + join_addresses = ','.join([str(a) for a in peer_addresses]) + # --insecure until the charm gets CA setup support figured out. + exec_start_line = (f'ExecStart={self.COCKROACH_BINARY_PATH} start --insecure ' + f'--advertise-addr={self._cluster.advertise_addr} ' + f'--join={join_addresses}') + ctxt = { + 'working_directory': self.WORKING_DIRECTORY, + 'exec_start_line': exec_start_line, + } + env = Environment(loader=FileSystemLoader('templates')) + template = env.get_template('cockroachdb.service') + rendered_content = template.render(ctxt) + + content_hash = hash(rendered_content) + # TODO: read the rendered file instead to account for any manual edits. + old_hash = getattr(self._stored, 'rendered_content_hash', None) + + if old_hash is None or old_hash != content_hash: + self._stored.rendered_content_hash = content_hash + with open(self.SYSTEMD_SERVICE_FILE, 'wb') as f: + f.write(rendered_content.encode('utf-8')) + subprocess.check_call(['systemctl', 'daemon-reload']) + + try: + pwd.getpwnam(self.COCKROACH_USERNAME) + except KeyError: + subprocess.check_call(['useradd', + '-m', + '--home-dir', + self.WORKING_DIRECTORY, + '--shell', + '/usr/sbin/nologin', + self.COCKROACH_USERNAME]) + + if self._stored.is_started: + subprocess.check_call(['systemctl', 'restart', f'{self.COCKROACHDB_SERVICE}']) diff --git a/templates/cockroachdb.service b/templates/cockroachdb.service new file mode 100644 index 0000000..677c9a2 --- /dev/null +++ b/templates/cockroachdb.service @@ -0,0 +1,14 @@ +[Unit] +Description=Cockroach Database cluster node +Requires=network.target +[Service] +Type=notify +WorkingDirectory={{ working_directory }} +{{ exec_start_line }} +TimeoutStopSec=60 +Restart=always +RestartSec=10 +SyslogIdentifier=cockroach +User=cockroach +[Install] +WantedBy=default.target diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_charm.py b/test/test_charm.py new file mode 100644 index 0000000..2385173 --- /dev/null +++ b/test/test_charm.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import unittest +import sys +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +from ops.framework import StoredState +from ops import testing +from ops.model import ActiveStatus, BlockedStatus + +from charm import CockroachDbCharm +from db_instance_manager import DbInstanceManager, DbInstanceManagerEvents + + +class TestDbInstanceManager(DbInstanceManager): + """A type used to replace DbInstanceManager during unit testing. + + It overrides methods that affect system state while leaving the rest of the + conditional logic untouched. + """ + + on = DbInstanceManagerEvents() + _stored = StoredState() + + def install(self): + self._install_called = True + super().install() + + def reconfigure(self): + self._reconfigure_called = True + super().reconfigure() + + def _install_binary(self): + pass + + def _run_start(self): + pass + + def _run_init(self): + pass + + def _setup_systemd_service(self): + pass + + def _get_cluster_id(self): + return '71edcae1-bf9c-4935-879e-bb380df72a32' + + +class TestCockroachDbCharm(unittest.TestCase): + + def setUp(self): + self.harness = testing.Harness(CockroachDbCharm) + # Inject a dummy instance manager so that it doesn't try to modify system state. + self.harness._charm_cls.instance_manager_cls = TestDbInstanceManager + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 0, + 'system-data-replicas': 0, + }) + + def test_install(self): + self.harness.begin() + self.harness.charm.on.install.emit() + self.assertTrue(self.harness.charm.instance_manager._install_called) + + def test_reconfigure(self): + self.harness.begin() + self.harness.charm.on.config_changed.emit() + self.assertTrue(self.harness.charm.instance_manager._reconfigure_called) + + def test_db_initialized_on_start_single(self): + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 1, + 'system-data-replicas': 1, + }) + self.harness.set_leader() + + # TODO: remove this line once https://github.com/canonical/operator/pull/196 is merged. + self.harness.add_relation('cluster', 'cockroachdb') + + self.harness.begin() + self.assertFalse(self.harness.charm.cluster.is_cluster_initialized) + self.harness.charm.on.start.emit() + self.assertTrue(self.harness.charm.cluster.is_cluster_initialized) + + def test_db_initialized_on_start_single_extra_unit(self): + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 1, + 'system-data-replicas': 1, + }) + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb', { + 'initial_unit': 'cockroachdb/1', + 'cluster_id': '71edcae1-bf9c-4935-879e-bb380df72a32' + }) + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/1', + {'ingress-address': '192.0.2.2'}) + + self.harness.begin() + self.assertTrue(self.harness.charm.cluster.is_cluster_initialized) + self.harness.charm.on.start.emit() + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + def test_db_initialized_on_start_ha_leader_late_peers(self): + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 3, + 'system-data-replicas': 3, + }) + self.harness.set_leader() + + # TODO: remove this line once https://github.com/canonical/operator/pull/196 is merged. + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + + self.harness.begin() + + self.harness.charm.on.start.emit() + # TODO: restore the following lines once PR #196 is merged. + # self.assertFalse(self.harness.charm.cluster.is_cluster_initialized) + # self.assertIsInstance(self.harness.charm.unit.status, WaitingStatus) + + # TODO: restore this line once https://github.com/canonical/operator/pull/196 is merged. + # relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/1', + {'ingress-address': '192.0.2.2'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/2', + {'ingress-address': '192.0.2.3'}) + + self.harness.charm.on.start.emit() + self.assertTrue(self.harness.charm.cluster.is_cluster_initialized) + self.assertEqual(self.harness.charm.cluster.initial_unit, + self.harness.charm.model.unit.name) + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + + def test_db_initialized_on_start_ha_leader_early_peers(self): + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 3, + 'system-data-replicas': 3, + }) + self.harness.set_leader() + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/1', + {'ingress-address': '192.0.2.2'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/2', + {'ingress-address': '192.0.2.3'}) + self.harness.begin() + + self.harness.charm.on.start.emit() + self.assertTrue(self.harness.charm.cluster.is_cluster_initialized) + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + + self.harness.charm.on.start.emit() + self.assertTrue(self.harness.charm.cluster.is_cluster_initialized) + self.assertEqual(self.harness.charm.cluster.initial_unit, + self.harness.charm.model.unit.name) + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + + def test_init_db_single(self): + self.harness.update_config({ + 'version': 'v19.2.2', + 'default-zone-replicas': 1, + 'system-data-replicas': 1, + }) + self.harness.set_leader(True) + self.harness.begin() + with self.assertRaises(RuntimeError): + self.harness.charm.instance_manager.init_db() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cluster.py b/test/test_cluster.py new file mode 100644 index 0000000..34d7188 --- /dev/null +++ b/test/test_cluster.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +import unittest +import sys +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +from ops import testing +from ops.charm import CharmBase, CharmEvents +from ops.framework import EventSource + +from cluster import CockroachDbCluster +from db_instance_manager import ClusterInitializedEvent + + +class TestCharmEvents(CharmEvents): + cluster_initialized = EventSource(ClusterInitializedEvent) + + +class TestCharm(CharmBase): + on = TestCharmEvents() + + +class TestCockroachDBCluster(unittest.TestCase): + + def setUp(self): + self.harness = testing.Harness(TestCharm, meta=''' + name: cockroachdb + peers: + cluster: + interface: cockroachdb-peer + ''') + + self.harness.begin() + self.cluster = CockroachDbCluster(self.harness.charm, 'cluster') + # A charm author is exptected to do that in the constructor so we mimic + # this here. + self.harness.framework.observe(self.harness.charm.on.cluster_initialized, + self.cluster.on_cluster_initialized) + + def test_is_cluster_joined(self): + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.assertTrue(self.cluster.is_joined) + + def test_is_single(self): + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.assertTrue(self.cluster.is_single) + + def test_peer_addresses(self): + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/1', + {'ingress-address': '192.0.2.2'}) + self.harness.add_relation_unit(relation_id, 'cockroachdb/2', + {'ingress-address': '192.0.2.3'}) + # Relation units are stored in a set hence the result may not + # always be ordered in the same way. + self.assertEqual(set(self.cluster.peer_addresses), set(['192.0.2.2', '192.0.2.3'])) + + def test_initial_unit(self): + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.assertIsNone(self.cluster.initial_unit) + + self.harness.update_relation_data( + relation_id, 'cockroachdb', { + 'cluster_id': '449ce7de-faea-48f1-925b-198032fdacc4', + 'initial_unit': 'cockroachdb/1' + }) + self.assertEqual(self.cluster.initial_unit, 'cockroachdb/1') + + def test_advertise_addr(self): + # TODO: implement when network_get gets implemented for the test harness. + pass + + def test_on_cluster_initialized_when_joined(self): + '''Test that the initial unit exposes a cluster id and reports the init state correctly. + ''' + self.harness.set_leader() + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.assertFalse(self.cluster.is_cluster_initialized) + + cluster_id = '449ce7de-faea-48f1-925b-198032fdacc4' + self.harness.charm.on.cluster_initialized.emit(cluster_id) + self.assertTrue(self.cluster.is_cluster_initialized) + + cluster_relation = self.harness.charm.model.get_relation('cluster') + app_data = cluster_relation.data[self.harness.charm.app] + self.assertEqual(app_data.get('cluster_id'), cluster_id) + self.assertEqual(app_data.get('initial_unit'), self.harness.charm.unit.name) + +# TODO: uncomment this once https://github.com/canonical/operator/pull/196 is merged. +# def test_on_cluster_initialized_when_not_joined(self): +# '''Test a scenario when an initial unit generates cluster state without a peer relation. +# +# This situation occurs on versions of Juju that do not have relation-created hooks fired +# before the start event. +# ''' +# self.harness.set_leader() +# self.assertFalse(self.cluster.is_cluster_initialized) +# +# cluster_id = '449ce7de-faea-48f1-925b-198032fdacc4' +# self.harness.charm.on.cluster_initialized.emit(cluster_id) +# self.assertTrue(self.cluster.is_cluster_initialized) +# self.assertTrue(self.cluster.stored.cluster_id, cluster_id) + + def test_on_cluster_initialized_not_leader(self): + '''Test that the handler raises an exception if erroneously used from a non-leader unit. + ''' + self.harness.set_leader(is_leader=False) + relation_id = self.harness.add_relation('cluster', 'cockroachdb') + self.harness.update_relation_data( + relation_id, 'cockroachdb/0', {'ingress-address': '192.0.2.1'}) + self.assertFalse(self.cluster.is_cluster_initialized) + + with self.assertRaises(RuntimeError): + self.harness.charm.on.cluster_initialized.emit('449ce7de-faea-48f1-925b-198032fdacc4') + + +if __name__ == "__main__": + unittest.main()