diff --git a/metadata.yaml b/metadata.yaml index c9649b0..b6ce97b 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -13,6 +13,8 @@ description: | requires: profiling-endpoint: interface: parca_scrape + external-parca-store-endpoint: + interface: parca_store provides: metrics-endpoint: diff --git a/src/charm.py b/src/charm.py index 84de782..7de4c11 100755 --- a/src/charm.py +++ b/src/charm.py @@ -33,6 +33,11 @@ def __init__(self, *args): self.framework.observe(self.on.remove, self._remove) self.framework.observe(self.on.update_status, self._update_status) + # Enable the option to send profiles to a remote store (i.e. Polar Signals Cloud) + self.framework.observe( + self.on.external_parca_store_endpoint_relation_changed, self._configure_remote_store + ) + # The profiling_consumer handles the relation that allows Parca to scrape other apps in the # model that provide a "profiling-endpoint" relation self.profiling_consumer = ProfilingEndpointConsumer(self) @@ -52,6 +57,7 @@ def __init__(self, *args): relation_name="self-profiling-endpoint", ) + # Enable Parca Agents to use this Parca instance as a remote store self.parca_store_endpoint = ParcaStoreEndpointProvider( charm=self, port=7070, insecure=True ) @@ -93,7 +99,15 @@ def _config_changed(self, _): """Update the configuration files, restart parca.""" self.unit.status = ops.MaintenanceStatus("reconfiguring parca") scrape_config = self.profiling_consumer.jobs() - self.parca.configure(self.config, scrape_config) + self.parca.configure(app_config=self.config, scrape_config=scrape_config) + self.unit.status = ops.ActiveStatus() + + def _configure_remote_store(self, event): + """Configure store with credentials passed over parca-external-store-endpoint relation.""" + self.unit.status = ops.MaintenanceStatus("reconfiguring parca") + rel_data = event.relation.data[event.relation.app] + rel_keys = ["remote-store-address", "remote-store-bearer-token", "remote-store-insecure"] + self.parca.configure(store_config={k: rel_data.get(k, "") for k in rel_keys}) self.unit.status = ops.ActiveStatus() def _remove(self, _): @@ -104,7 +118,7 @@ def _remove(self, _): def _on_profiling_targets_changed(self, _): """Update the Parca scrape configuration according to present relations.""" self.unit.status = ops.MaintenanceStatus("reconfiguring parca") - self.parca.configure(self.config, self.profiling_consumer.jobs()) + self.parca.configure(app_config=self.config, scrape_config=self.profiling_consumer.jobs()) self.unit.status = ops.ActiveStatus() def _open_port(self) -> bool: diff --git a/src/parca.py b/src/parca.py index 5253c69..9075c95 100644 --- a/src/parca.py +++ b/src/parca.py @@ -4,8 +4,10 @@ """Control Parca on a host system. Provides a Parca class.""" import logging +from pathlib import Path from subprocess import check_output +import yaml from charms.operator_libs_linux.v1 import snap from charms.parca.v0.parca_config import ParcaConfig, parse_version @@ -21,7 +23,7 @@ class Parca: def install(self): """Install the Parca snap package.""" try: - self._snap.ensure(snap.SnapState.Latest, channel="stable") + self._snap.ensure(snap.SnapState.Latest, channel="edge") snap.hold_refresh() except snap.SnapError as e: logger.error("could not install parca. Reason: %s", e.message) @@ -45,17 +47,35 @@ def remove(self): """Remove the Parca snap, preserving config and data.""" self._snap.ensure(snap.SnapState.Absent) - def configure(self, app_config, scrape_configs=[], restart=True): + def configure(self, *, app_config=None, scrape_config=None, store_config=None, restart=True): """Configure Parca on the host system. Restart Parca by default.""" - # Configure the snap appropriately - if app_config["enable-persistence"]: - self._snap.set({"enable-persistence": "true"}) + if app_config: + if app_config.get("enable-persistence", None): + self._snap.set({"enable-persistence": "true"}) + else: + limit = app_config["memory-storage-limit"] * 1048576 + self._snap.set({"enable-persistence": "false", "storage-active-memory": limit}) + + if store_config: + if addr := store_config.get("remote-store-address", None): + self._snap.set({"remote-store-address": addr}) + + if token := store_config.get("remote-store-bearer-token", None): + self._snap.set({"remote-store-bearer-token": token}) + + if insecure := store_config.get("remote-store-insecure", None): + self._snap.set({"remote-store-insecure": insecure}) + + if scrape_config: + # If the scrape configs are explicitly set, then build the config from new + parca_config = ParcaConfig(scrape_config, profile_path=self.PROFILE_PATH) else: - limit = app_config["memory-storage-limit"] * 1048576 - self._snap.set({"enable-persistence": "false", "storage-active-memory": limit}) + # Otherwise grab existing scrape jobs and build a config to include them + old = yaml.safe_load(Path(self.CONFIG_PATH).read_text()) + parca_config = ParcaConfig( + old.get("scrape_configs", []), profile_path=self.PROFILE_PATH + ) - # Write the config file - parca_config = ParcaConfig(scrape_configs, profile_path=self.PROFILE_PATH) with open(self.CONFIG_PATH, "w+") as f: f.write(str(parca_config)) diff --git a/tests/functional/test_parca.py b/tests/functional/test_parca.py index e51e705..57cdc36 100644 --- a/tests/functional/test_parca.py +++ b/tests/functional/test_parca.py @@ -5,6 +5,7 @@ from pathlib import Path from subprocess import check_call +import yaml from charms.parca.v0.parca_config import ParcaConfig from parca import Parca @@ -45,23 +46,90 @@ def test_remove(self): self.assertFalse(self.parca.installed) def test_configure_systemd_storage_persist(self): - self.parca.configure({"enable-persistence": True}) + self.parca.configure(app_config={"enable-persistence": True}) self.assertEqual(self.parca._snap.get("enable-persistence"), "true") def test_configure_systemd_storage_in_memory(self): - self.parca.configure(DEFAULT_PARCA_CONFIG) + self.parca.configure(app_config=DEFAULT_PARCA_CONFIG) self.assertEqual(self.parca._snap.get("enable-persistence"), "false") self.assertEqual(self.parca._snap.get("storage-active-memory"), "1073741824") def test_configure_parca_no_scrape_jobs(self): - self.parca.configure(DEFAULT_PARCA_CONFIG) - config = ParcaConfig([], profile_path="/var/snap/parca/current/profiles") + self.parca.configure(app_config=DEFAULT_PARCA_CONFIG) + old = yaml.safe_load(Path(self.parca.CONFIG_PATH).read_text()) + config = ParcaConfig( + old.get("scrape_configs", []), profile_path="/var/snap/parca/current/profiles" + ) self.assertTrue(_file_content_equals_string(self.parca.CONFIG_PATH, str(config))) def test_configure_parca_simple_scrape_jobs(self): - self.parca.configure(DEFAULT_PARCA_CONFIG, [{"metrics_path": "foobar", "bar": "baz"}]) + self.parca.configure( + app_config=DEFAULT_PARCA_CONFIG, + scrape_config=[{"metrics_path": "foobar", "bar": "baz"}], + ) config = ParcaConfig( [{"metrics_path": "foobar", "bar": "baz"}], profile_path="/var/snap/parca/current/profiles", ) self.assertTrue(_file_content_equals_string(self.parca.CONFIG_PATH, str(config))) + + def test_configure_parca_store_config(self): + self.parca.configure( + store_config={ + "remote-store-address": "grpc.polarsignals.com:443", + "remote-store-bearer-token": "deadbeef", + "remote-store-insecure": "false", + } + ) + self.assertEqual(self.parca._snap.get("remote-store-address"), "grpc.polarsignals.com:443") + self.assertEqual(self.parca._snap.get("remote-store-bearer-token"), "deadbeef") + self.assertEqual(self.parca._snap.get("remote-store-insecure"), "false") + + def test_configure_parca_store_config_no_conflict_with_app_config(self): + # Setup baseline config + self.parca.configure(app_config=DEFAULT_PARCA_CONFIG) + self.assertEqual(self.parca._snap.get("enable-persistence"), "false") + self.assertEqual(self.parca._snap.get("storage-active-memory"), "1073741824") + + # Setup some store config + self.parca.configure( + store_config={ + "remote-store-address": "grpc.polarsignals.com:443", + "remote-store-bearer-token": "deadbeef", + "remote-store-insecure": "false", + } + ) + + self.assertEqual(self.parca._snap.get("remote-store-address"), "grpc.polarsignals.com:443") + self.assertEqual(self.parca._snap.get("remote-store-bearer-token"), "deadbeef") + self.assertEqual(self.parca._snap.get("remote-store-insecure"), "false") + + # Check we didn't mess with the app_config + self.assertEqual(self.parca._snap.get("enable-persistence"), "false") + self.assertEqual(self.parca._snap.get("storage-active-memory"), "1073741824") + + def test_configure_parca_store_config_no_conflict_with_scrape_config(self): + self.parca.configure( + app_config=DEFAULT_PARCA_CONFIG, + scrape_config=[{"metrics_path": "foobar", "bar": "baz"}], + ) + expected = ParcaConfig( + [{"metrics_path": "foobar", "bar": "baz"}], + profile_path="/var/snap/parca/current/profiles", + ) + self.assertTrue(_file_content_equals_string(self.parca.CONFIG_PATH, str(expected))) + + # Setup some store config + self.parca.configure( + store_config={ + "remote-store-address": "grpc.polarsignals.com:443", + "remote-store-bearer-token": "deadbeef", + "remote-store-insecure": "false", + } + ) + + self.assertEqual(self.parca._snap.get("remote-store-address"), "grpc.polarsignals.com:443") + self.assertEqual(self.parca._snap.get("remote-store-bearer-token"), "deadbeef") + self.assertEqual(self.parca._snap.get("remote-store-insecure"), "false") + + self.assertTrue(_file_content_equals_string(self.parca.CONFIG_PATH, str(expected))) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index a2a9a84..ccbb3cf 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -92,7 +92,7 @@ def test_config_changed(self, configure): "memory-storage-limit": 1024, } self.harness.update_config(config) - configure.assert_called_with(config, []) + configure.assert_called_with(app_config=config, scrape_config=[]) self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) @patch("charm.Parca.remove") @@ -192,9 +192,8 @@ def test_metrics_endpoint_relation(self, _): } self.assertEqual(unit_data, expected) - @patch("charm.Parca.configure") @patch("ops.model.Model.get_binding", lambda *args: MockBinding("10.10.10.10")) - def test_parca_store_relation(self, _): + def test_parca_store_relation(self): self.harness.set_leader(True) # Create a relation to an app named "parca-agent" rel_id = self.harness.add_relation("parca-store-endpoint", "parca-agent") @@ -209,6 +208,26 @@ def test_parca_store_relation(self, _): } self.assertEqual(unit_data, expected) + @patch("charm.Parca.configure") + @patch("ops.model.Model.get_binding", lambda *args: MockBinding("10.10.10.10")) + def test_parca_external_store_relation(self, configure): + self.harness.set_leader(True) + # Create a relation to an app named "polar-signals-cloud" + rel_id = self.harness.add_relation("external-parca-store-endpoint", "polar-signals-cloud") + # Add a polar-signals-cloud unit + self.harness.add_relation_unit(rel_id, "polar-signals-cloud/0") + # Set some data from the remote application + store_config = { + "remote-store-address": "grpc.polarsignals.com:443", + "remote-store-bearer-token": "deadbeef", + "remote-store-insecure": "false", + } + self.harness.update_relation_data(rel_id, "polar-signals-cloud", store_config) + + # Ensure that we call the configure method on Parca with the correct store details + configure.assert_called_with(store_config=store_config) + self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) + class MockBinding: def __init__(self, addr):