From 1f11ce4056514a97c4e3c9d92858bb1a4dc741eb Mon Sep 17 00:00:00 2001 From: Himawan Winarto Date: Wed, 17 Jun 2026 03:00:54 -0400 Subject: [PATCH 1/2] feat: add S3 support for telemetry feature Signed-off-by: Himawan Winarto --- sunbeam-python/sunbeam/core/juju.py | 76 +++++++ .../sunbeam/features/telemetry/README.md | 7 +- .../sunbeam/features/telemetry/feature.py | 185 ++++++++++++++++- .../tests/unit/sunbeam/core/test_juju.py | 104 ++++++++++ .../unit/sunbeam/features/test_telemetry.py | 186 ++++++++++++++++++ 5 files changed, 548 insertions(+), 10 deletions(-) diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index e1f095f8d..640690dda 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -2076,6 +2076,61 @@ def get_external_controllers(self) -> list: return external_controllers + def get_controller_config(self, controller_name: str) -> dict | None: + """Return connection details for a registered controller. + + Uses ``get_controller`` for the API endpoint and CA certificate, + and ``~/.local/share/juju/accounts.yaml`` for credentials. + Only returns a result if the controller is registered locally. + + :return: A dict with keys ``controller_addresses``, ``username``, + ``password``, ``ca_certificate``, or ``None``. + """ + try: + ctrl = self.get_controller(controller_name) + except ControllerNotFoundException: + LOG.warning( + "Controller %r is not registered as Juju controller. " + "Register it with `juju register` first.", + controller_name, + ) + return None + + details = ctrl.get("details", {}) + api_endpoints = details.get("api-endpoints") + ca_cert = details.get("ca-cert") + if not api_endpoints or not ca_cert: + return None + + try: + juju_data_dir = Path( + os.environ.get( + "JUJU_DATA", f"{os.environ.get('SNAP_REAL_HOME')}/.local/share/juju" + ) + ) + accounts_data = yaml.safe_load( + (juju_data_dir / "accounts.yaml").read_text() + ) + except (FileNotFoundError, yaml.YAMLError): + LOG.debug("No local accounts.yaml found for %r", controller_name) + return None + + acct = accounts_data.get("controllers", {}).get(controller_name) + if not acct: + return None + + user = acct.get("user") + password = acct.get("password") + if not user or not password: + return None + + return { + "controller_addresses": api_endpoints[0], + "username": user, + "password": password, + "ca_certificate": ca_cert, + } + def get_controller(self, controller: str) -> dict: """Get controller definition.""" try: @@ -2375,3 +2430,24 @@ def run_action( except ActionFailedException as e: LOG.debug("Action %r failed on node %r: %r", action_name, node, e) raise e + + +def split_controller_from_offer_url(url: str) -> tuple[str | None, str]: + """Split the controller prefix from an offer URL. + + Juju offer URLs have the form: + ``[:][/].[:]`` + + Returns a tuple of ``(controller, offer_url_without_controller)``. + The controller is ``None`` if the URL does not include a controller prefix. + + :param url: The offer URL to split. + :return: A tuple of (controller, offer_url_without_controller). + :raises ValueError: If the URL is not a valid offer URL. + """ + parts = url.split(":", 1) + if len(parts) == 2 and "." in parts[0]: + return None, url + if len(parts) == 2: + return parts[0], parts[1] + return None, url diff --git a/sunbeam-python/sunbeam/features/telemetry/README.md b/sunbeam-python/sunbeam/features/telemetry/README.md index b86730a66..69ea2cde3 100644 --- a/sunbeam-python/sunbeam/features/telemetry/README.md +++ b/sunbeam-python/sunbeam/features/telemetry/README.md @@ -1,6 +1,6 @@ # Telemetry service -This feature provides Telemetry service for Sunbeam. It is based on OpenStack Telemetry projects [Ceilometer](https://docs.openstack.org/designate/latest/), [Aodh](https://docs.openstack.org/aodh/latest/), [Gnocchi](https://wiki.openstack.org/wiki/Gnocchi). +This feature provides Telemetry service for Sunbeam. It is based on OpenStack Telemetry projects [Ceilometer](https://docs.openstack.org/ceilometer/latest/), [Aodh](https://docs.openstack.org/aodh/latest/), [Gnocchi](https://wiki.openstack.org/wiki/Gnocchi). ## Installation @@ -13,11 +13,14 @@ sunbeam enable telemetry ## Contents This feature will install the following services: + - Ceilometer: Data collection service [charm](https://opendev.org/openstack/charm-ceilometer-k8s) [ROCK](https://github.com/canonical/ubuntu-openstack-rocks/tree/main/rocks/ceilometer-consolidated) - Aodh: Alarming service [charm](https://opendev.org/openstack/charm-aodh-k8s) [ROCK](https://github.com/canonical/ubuntu-openstack-rocks/tree/main/rocks/aodh-consolidated) - Gnocchi: Time series database service [charm](https://opendev.org/openstack/charm-gnocchi-k8s) [ROCK](https://github.com/canonical/ubuntu-openstack-rocks/tree/main/rocks/gnocchi-consolidated) - Ceilometer Agent: Agent on hypervisor [charm](https://opendev.org/openstack/charm-openstack-hypervisor) [SNAP](https://github.com/canonical/snap-openstack-hypervisor.git) -- MySQL Router for Designate [charm](https://github.com/canonical/mysql-router-k8s-operator) [ROCK](https://github.com/canonical/charmed-mysql-rock) +- TODO: ADD OPENSTACK EXPORTER INFO +- MySQL Router for Aodh [charm](https://github.com/canonical/mysql-router-k8s-operator) [ROCK](https://github.com/canonical/charmed-mysql-rock) +- MySQL Router for Gnocchi [charm](https://github.com/canonical/mysql-router-k8s-operator) [ROCK](https://github.com/canonical/charmed-mysql-rock) - MySQL Instance in the case of a multi-mysql installation (for large deployments) [charm](https://github.com/canonical/mysql-k8s-operator) [ROCK](https://github.com/canonical/charmed-mysql-rock) Services are constituted of charms, i.e. operator code, and ROCKs, the corresponding OCI images. diff --git a/sunbeam-python/sunbeam/features/telemetry/feature.py b/sunbeam-python/sunbeam/features/telemetry/feature.py index 817d9a939..161e08e0a 100644 --- a/sunbeam-python/sunbeam/features/telemetry/feature.py +++ b/sunbeam-python/sunbeam/features/telemetry/feature.py @@ -1,15 +1,21 @@ # SPDX-FileCopyrightText: 2023 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 +import enum import logging import click +import pydantic from packaging.version import Version from rich.console import Console -from sunbeam.core.common import BaseStep, run_plan +from sunbeam.core.common import BaseStep, SunbeamException, run_plan from sunbeam.core.deployment import Deployment -from sunbeam.core.juju import JujuHelper +from sunbeam.core.juju import ( + JujuHelper, + JujuStepHelper, + split_controller_from_offer_url, +) from sunbeam.core.manifest import ( AddManifestStep, CharmManifest, @@ -17,6 +23,7 @@ SoftwareConfig, ) from sunbeam.core.openstack import OPENSTACK_MODEL +from sunbeam.core.questions import load_answers, write_answers from sunbeam.core.terraform import TerraformInitStep from sunbeam.features.interface.v1.openstack import ( DisableOpenStackApplicationStep, @@ -35,6 +42,41 @@ LOG = logging.getLogger(__name__) console = Console() +TELEMETRY_METRICS_BACKEND_KEY = "TelemetryMetricsBackend" + + +class MetricsBackendType(enum.Enum): + """Telemetry metrics storage backend types.""" + + LOCAL = "local" + S3 = "s3" + + +class TelemetryFeatureConfig(FeatureConfig): + """Telemetry feature configuration.""" + + model_config = pydantic.ConfigDict(populate_by_name=True) + + s3_integrator_offer_url: str | None = pydantic.Field( + default=None, + description=( + "Juju offer URL of an externally deployed s3-integrator application " + "to use for Gnocchi metrics storage." + ), + validation_alias=pydantic.AliasChoices( + "s3-integrator-offer-url", + "s3_integrator_offer_url", + ), + serialization_alias="s3-integrator-offer-url", + ) + + +class TelemetryMetricsBackendConfig(pydantic.BaseModel): + """Persisted metrics storage backend configuration.""" + + backend: MetricsBackendType | None = None + s3_integrator_offer_url: str | None = None + class TelemetryFeature(OpenStackControlPlaneFeature): version = Version("0.0.1") @@ -42,6 +84,56 @@ class TelemetryFeature(OpenStackControlPlaneFeature): name = "telemetry" tf_plan_location = TerraformPlanLocation.SUNBEAM_TERRAFORM_REPO + def __init__(self) -> None: + super().__init__() + self.s3_integrator_offer_url: str | None = None + + def config_type(self) -> type[TelemetryFeatureConfig]: + """Feature config type.""" + return TelemetryFeatureConfig + + def _load_metrics_config( + self, deployment: Deployment + ) -> TelemetryMetricsBackendConfig: + """Load the metrics storage backend config from cluster DB.""" + client = deployment.get_client() + answers = load_answers(client, TELEMETRY_METRICS_BACKEND_KEY) + return TelemetryMetricsBackendConfig.model_validate(answers) + + def _save_metrics_config( + self, deployment: Deployment, config: TelemetryMetricsBackendConfig + ) -> None: + """Save the metrics storage backend config to cluster DB.""" + client = deployment.get_client() + write_answers( + client, + TELEMETRY_METRICS_BACKEND_KEY, + config.model_dump(mode="json", exclude_none=True), + ) + + def _config_uses_external_storage( + self, config: TelemetryMetricsBackendConfig + ) -> bool: + """Whether a persisted metrics backend config uses external storage.""" + return config.backend == MetricsBackendType.S3 + + def _set_s3_integrator_application_from_config(self, config: FeatureConfig) -> None: + """Set the requested external s3-integrator offer URL from config.""" + if isinstance(config, TelemetryFeatureConfig): + self.s3_integrator_offer_url = config.s3_integrator_offer_url + + def _has_local_metrics_storage(self, deployment: Deployment) -> bool: + """Check if local metrics storage is available for Gnocchi.""" + return bool(deployment.get_client().cluster.list_nodes_by_role("storage")) + + def _has_metrics_storage(self, deployment: Deployment) -> bool: + """Check if metrics storage is available for Gnocchi.""" + if self.s3_integrator_offer_url: + return True + if self._config_uses_external_storage(self._load_metrics_config(deployment)): + return True + return self._has_local_metrics_storage(deployment) + def default_software_overrides(self) -> SoftwareConfig: """Feature software configuration.""" return SoftwareConfig( @@ -86,6 +178,8 @@ def run_enable_plans( self, deployment: Deployment, config: FeatureConfig, show_hints: bool ) -> None: """Run plans to enable feature.""" + self._set_s3_integrator_application_from_config(config) + tfhelper = deployment.get_tfhelper(self.tfplan) tfhelper_openstack = deployment.get_tfhelper("openstack-plan") tfhelper_hypervisor = deployment.get_tfhelper("hypervisor-plan") @@ -98,12 +192,35 @@ def run_enable_plans( [ TerraformInitStep(tfhelper), EnableOpenStackApplicationStep( - deployment, config, tfhelper, jhelper, self + deployment, + config, + tfhelper, + jhelper, + self, + app_desired_status=( + ["active", "blocked"] + if self.s3_integrator_offer_url + else ["active"] + ), ), ] ) run_plan(plan1, console, show_hints) + if self.s3_integrator_offer_url: + self._save_metrics_config( + deployment, + TelemetryMetricsBackendConfig( + backend=MetricsBackendType.S3, + s3_integrator_offer_url=self.s3_integrator_offer_url, + ), + ) + else: + self._save_metrics_config( + deployment, + TelemetryMetricsBackendConfig(backend=MetricsBackendType.LOCAL), + ) + openstack_tf_output = tfhelper_openstack.output() extra_tfvars = { "ceilometer-offer-url": openstack_tf_output.get("ceilometer-offer-url") @@ -317,6 +434,7 @@ def run_disable_plans(self, deployment: Deployment, show_hints: bool) -> None: run_plan(plan2, console, show_hints) click.echo(f"OpenStack {self.display_name} application disabled.") + self._save_metrics_config(deployment, TelemetryMetricsBackendConfig()) def set_application_names(self, deployment: Deployment) -> list: """Application names handled by the terraform plan.""" @@ -326,7 +444,7 @@ def set_application_names(self, deployment: Deployment) -> list: if database_topology == "multi": apps.append("aodh-mysql") - if deployment.get_client().cluster.list_nodes_by_role("storage"): + if self._has_metrics_storage(deployment): apps.extend(["ceilometer", "gnocchi", "gnocchi-mysql-router"]) if database_topology == "multi": apps.append("gnocchi-mysql") @@ -341,13 +459,46 @@ def set_tfvars_on_enable( self, deployment: Deployment, config: FeatureConfig ) -> dict: """Set terraform variables to enable the application.""" - return { + self._set_s3_integrator_application_from_config(config) + + tfvars: dict = { "enable-telemetry": True, + "enable-telemetry-s3-storage": False, + "telemetry-s3-integrator-offer-url": None, + "telemetry-s3-integrator-offering-controller": None, } + if self.s3_integrator_offer_url: + # Split the controller from the offer URL if it is present + controller, s3_url = split_controller_from_offer_url( + self.s3_integrator_offer_url + ) + tfvars.update( + { + "enable-telemetry-s3-storage": True, + "telemetry-s3-integrator-offer-url": s3_url, + "telemetry-s3-integrator-offering-controller": controller, + } + ) + # Validate and register the offering controller + if controller: + ctrl_config = JujuStepHelper().get_controller_config(controller) + if not ctrl_config: + raise SunbeamException( + f"Telemetry S3 offering controller {controller} is not " + "registered in Juju provider" + ) + tfvars.update({"remote-controllers": {controller: ctrl_config}}) + + return tfvars def set_tfvars_on_disable(self, deployment: Deployment) -> dict: """Set terraform variables to disable the application.""" - return {"enable-telemetry": False} + return { + "enable-telemetry": False, + "enable-telemetry-s3-storage": False, + "telemetry-s3-integrator-offer-url": None, + "telemetry-s3-integrator-offering-controller": None, + } def set_tfvars_on_resize( self, deployment: Deployment, config: FeatureConfig @@ -363,11 +514,29 @@ def get_database_charm_processes(self) -> dict[str, dict[str, int]]: } @click.command() + @click.option( + "--s3-integrator-offer-url", + "s3_integrator_offer_url", + default=None, + help=( + "Juju offer URL of an externally deployed s3-integrator application " + "to use for Gnocchi metrics storage. Format: " + "``[:][/].``" + ), + ) @click_option_show_hints @pass_method_obj - def enable_cmd(self, deployment: Deployment, show_hints: bool) -> None: + def enable_cmd( + self, + deployment: Deployment, + s3_integrator_offer_url: str | None, + show_hints: bool, + ) -> None: """Enable OpenStack Telemetry applications.""" - self.enable_feature(deployment, FeatureConfig(), show_hints) + config = TelemetryFeatureConfig( + s3_integrator_offer_url=s3_integrator_offer_url, + ) + self.enable_feature(deployment, config, show_hints) @click.command() @click_option_show_hints diff --git a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py index 7a248e4e7..6fa4e4600 100644 --- a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py +++ b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import json +import os from unittest.mock import MagicMock, Mock, patch import jubilant @@ -1400,3 +1401,106 @@ def test_snapshot_workload_status_mixed_present_and_absent(jhelper, status): status.apps["present"] = app_mock result = jhelper.snapshot_workload_status("test-model", ["present", "absent"]) assert result == {"present": "active"} + + +# --------------------------------------------------------------------------- +# split_controller_from_offer_url +# --------------------------------------------------------------------------- + + +class TestSplitControllerFromOfferUrl: + def test_url_with_controller(self): + ctrl, url = jujulib.split_controller_from_offer_url( + "localhost-localhost:admin/s3-storage.s3-integrator" + ) + assert ctrl == "localhost-localhost" + assert url == "admin/s3-storage.s3-integrator" + + def test_url_without_controller(self): + ctrl, url = jujulib.split_controller_from_offer_url( + "admin/s3-storage.s3-integrator" + ) + assert ctrl is None + assert url == "admin/s3-storage.s3-integrator" + + def test_url_with_endpoint(self): + ctrl, url = jujulib.split_controller_from_offer_url("admin/model.app:endpoint") + assert ctrl is None + assert url == "admin/model.app:endpoint" + + def test_bare_controller_model(self): + ctrl, url = jujulib.split_controller_from_offer_url("my-ctrl:admin/model.app") + assert ctrl == "my-ctrl" + assert url == "admin/model.app" + + def test_no_colon(self): + ctrl, url = jujulib.split_controller_from_offer_url( + "admin/controller.microceph" + ) + assert ctrl is None + assert url == "admin/controller.microceph" + + +# --------------------------------------------------------------------------- +# get_controller_config +# --------------------------------------------------------------------------- + + +class TestGetControllerConfig: + def test_returns_config_when_registered(self, tmp_path): + accounts = {"controllers": {"myctrl": {"user": "admin", "password": "s3cret"}}} + juju_dir = tmp_path / ".local" / "share" / "juju" + juju_dir.mkdir(parents=True) + (juju_dir / "accounts.yaml").write_text(yaml.dump(accounts)) + + jsh = jujulib.JujuStepHelper() + jsh.jhelper = Mock() + jsh.get_external_controllers = Mock(return_value=["myctrl"]) + jsh.get_controller = Mock( + return_value={ + "details": { + "api-endpoints": ["10.0.0.1:17070"], + "ca-cert": "FAKE-CERT", + } + } + ) + + with patch.dict(os.environ, {"JUJU_DATA": str(juju_dir)}): + result = jsh.get_controller_config("myctrl") + + assert result == { + "controller_addresses": "10.0.0.1:17070", + "username": "admin", + "password": "s3cret", + "ca_certificate": "FAKE-CERT", + } + + def test_returns_none_when_not_registered(self): + jsh = jujulib.JujuStepHelper() + jsh.jhelper = Mock() + jsh.get_controller = Mock(side_effect=jujulib.ControllerNotFoundException) + + result = jsh.get_controller_config("unknown") + assert result is None + + def test_returns_none_when_accounts_missing(self, tmp_path): + jsh = jujulib.JujuStepHelper() + jsh.jhelper = Mock() + jsh.get_external_controllers = Mock(return_value=["myctrl"]) + jsh.get_controller = Mock( + return_value={ + "details": { + "api-endpoints": ["10.0.0.1:17070"], + "ca-cert": "FAKE-CERT", + } + } + ) + + juju_dir = tmp_path / ".local" / "share" / "juju" + juju_dir.mkdir(parents=True) + # No accounts.yaml created — should return None + + with patch.dict(os.environ, {"JUJU_DATA": str(juju_dir)}): + result = jsh.get_controller_config("myctrl") + + assert result is None diff --git a/sunbeam-python/tests/unit/sunbeam/features/test_telemetry.py b/sunbeam-python/tests/unit/sunbeam/features/test_telemetry.py index e74d95d75..ba2e2b817 100644 --- a/sunbeam-python/tests/unit/sunbeam/features/test_telemetry.py +++ b/sunbeam-python/tests/unit/sunbeam/features/test_telemetry.py @@ -1,10 +1,15 @@ # SPDX-FileCopyrightText: 2025 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 +import copy +import json from unittest.mock import Mock, patch import pytest +from click.testing import CliRunner +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.features.interface.v1.base import ClickInstantiator from sunbeam.features.telemetry import feature as telemetry_feature @@ -16,6 +21,7 @@ def deployment(): client = deploy.get_client.return_value client.cluster.list_nodes_by_role.return_value = [{"name": "node1", "machineid": 1}] + client.cluster.get_config.side_effect = ConfigItemNotFoundException() return deploy @@ -343,3 +349,183 @@ def test_run_disable_plans_passes_extra_tfvars( for call in mock_deploy_step_class.call_args_list: extra_tfvars = call[1]["extra_tfvars"] assert extra_tfvars == {"enable-telemetry-notifications": False} + + +class TestTelemetryMetricsStorage: + """Test metrics storage backend handling for Gnocchi.""" + + def test_set_tfvars_on_enable_local(self, deployment): + """No S3 integrator -> local metrics storage is used.""" + feature = telemetry_feature.TelemetryFeature() + + assert feature.set_tfvars_on_enable( + deployment, telemetry_feature.TelemetryFeatureConfig() + ) == { + "enable-telemetry": True, + "enable-telemetry-s3-storage": False, + "telemetry-s3-integrator-offer-url": None, + "telemetry-s3-integrator-offering-controller": None, + } + + def test_set_tfvars_on_enable_with_external_s3_offer_url(self, deployment): + """External S3 integrator -> pass offer URL to terraform.""" + feature = telemetry_feature.TelemetryFeature() + + assert feature.set_tfvars_on_enable( + deployment, + telemetry_feature.TelemetryFeatureConfig( + s3_integrator_offer_url="admin/storage.gnocchi-s3" + ), + ) == { + "enable-telemetry": True, + "enable-telemetry-s3-storage": True, + "telemetry-s3-integrator-offer-url": "admin/storage.gnocchi-s3", + "telemetry-s3-integrator-offering-controller": None, + } + + def test_set_tfvars_on_disable_clears_s3(self, deployment): + """Disable clears telemetry and S3 storage variables.""" + feature = telemetry_feature.TelemetryFeature() + + assert feature.set_tfvars_on_disable(deployment) == { + "enable-telemetry": False, + "enable-telemetry-s3-storage": False, + "telemetry-s3-integrator-offer-url": None, + "telemetry-s3-integrator-offering-controller": None, + } + + def test_set_application_names_uses_persisted_external_backend(self, deployment): + """Persisted S3 backend makes Gnocchi expected without storage nodes.""" + client = deployment.get_client.return_value + client.cluster.list_nodes_by_role.return_value = [] + client.cluster.get_config.side_effect = None + client.cluster.get_config.return_value = json.dumps( + { + "backend": "s3", + "s3_integrator_offer_url": "admin/storage.gnocchi-s3", + } + ) + + feature = telemetry_feature.TelemetryFeature() + feature.get_database_topology = Mock(return_value="single") + + assert feature.set_application_names(deployment) == [ + "aodh", + "aodh-mysql-router", + "openstack-exporter", + "ceilometer", + "gnocchi", + "gnocchi-mysql-router", + ] + + @patch("sunbeam.features.telemetry.feature.JujuHelper") + @patch("sunbeam.features.telemetry.feature.StorageBackendManager") + @patch("sunbeam.features.telemetry.feature.run_plan") + def test_run_enable_plans_with_external_s3_integrator( + self, + mock_run_plan, + mock_storage_manager_class, + mock_jhelper_class, + deployment, + ): + """External S3 persists the offer URL.""" + client = deployment.get_client.return_value + client.cluster.list_nodes_by_role.return_value = [] + storage_backends_root = Mock() + storage_backends_root.root = [] + client.cluster.get_storage_backends.return_value = storage_backends_root + + tfhelper = Mock() + tfhelper_openstack = Mock() + tfhelper_openstack.output.return_value = {"ceilometer-offer-url": "url"} + tfhelper_hypervisor = Mock() + tfhelper_cinder_volume = Mock() + + deployment.get_tfhelper.side_effect = lambda plan: { + "telemetry-plan": tfhelper, + "openstack-plan": tfhelper_openstack, + "hypervisor-plan": tfhelper_hypervisor, + "cinder-volume-plan": tfhelper_cinder_volume, + }[plan] + + feature = telemetry_feature.TelemetryFeature() + feature._manifest = Mock() + feature.run_enable_plans( + deployment, + telemetry_feature.TelemetryFeatureConfig( + s3_integrator_offer_url="admin/storage.gnocchi-s3" + ), + False, + ) + + first_plan = mock_run_plan.call_args_list[0][0][0] + assert first_plan[1].app_desired_status == ["active", "blocked"] + + saved = json.loads(client.cluster.update_config.call_args[0][1]) + assert saved == { + "backend": "s3", + "s3_integrator_offer_url": "admin/storage.gnocchi-s3", + } + + @patch("sunbeam.features.telemetry.feature.JujuHelper") + @patch("sunbeam.features.telemetry.feature.run_plan") + def test_run_enable_plans_without_storage_or_s3( + self, + mock_run_plan, + mock_jhelper_class, + deployment, + ): + """Without storage or S3, telemetry enables but skips ceilometer/gnocchi.""" + client = deployment.get_client.return_value + client.cluster.list_nodes_by_role.return_value = [] + storage_backends_root = Mock() + storage_backends_root.root = [] + client.cluster.get_storage_backends.return_value = storage_backends_root + + tfhelper = Mock() + tfhelper_openstack = Mock() + tfhelper_openstack.output.return_value = {"ceilometer-offer-url": "url"} + tfhelper_hypervisor = Mock() + tfhelper_cinder_volume = Mock() + + deployment.get_tfhelper.side_effect = lambda plan: { + "telemetry-plan": tfhelper, + "openstack-plan": tfhelper_openstack, + "hypervisor-plan": tfhelper_hypervisor, + "cinder-volume-plan": tfhelper_cinder_volume, + }[plan] + + feature = telemetry_feature.TelemetryFeature() + feature._manifest = Mock() + feature.run_enable_plans( + deployment, telemetry_feature.TelemetryFeatureConfig(), False + ) + + # Verify the plan ran without error + assert mock_run_plan.called + + +class TestTelemetryEnableCli: + """Test the `enable telemetry` command options.""" + + def test_enable_command_accepts_s3_offer_url(self, deployment): + feature = telemetry_feature.TelemetryFeature() + command = copy.copy(feature.enable_cmd) + if not isinstance(command.callback, ClickInstantiator): + command.callback = ClickInstantiator(command.callback, feature) + + with patch.object(feature, "enable_feature") as mock_enable: + result = CliRunner().invoke( + command, + [ + "--s3-integrator-offer-url", + "admin/storage.gnocchi-s3", + ], + obj=deployment, + ) + + assert result.exit_code == 0, result.output + assert mock_enable.called + config = mock_enable.call_args[0][1] + assert isinstance(config, telemetry_feature.TelemetryFeatureConfig) + assert config.s3_integrator_offer_url == "admin/storage.gnocchi-s3" From 1498d9c15ff336dbd4f167cd43ce263d386aca08 Mon Sep 17 00:00:00 2001 From: Himawan Winarto Date: Wed, 17 Jun 2026 08:52:50 -0400 Subject: [PATCH 2/2] feat: add S3 support for glance and ironic Signed-off-by: Himawan Winarto --- .../sunbeam/features/baremetal/steps.py | 16 ++ sunbeam-python/sunbeam/steps/openstack.py | 83 +++++++++ .../baremetal/test_baremetal_steps.py | 19 ++ .../unit/sunbeam/steps/test_openstack.py | 163 ++++++++++++++++++ 4 files changed, 281 insertions(+) diff --git a/sunbeam-python/sunbeam/features/baremetal/steps.py b/sunbeam-python/sunbeam/features/baremetal/steps.py index 95ab5bf38..517b8818c 100644 --- a/sunbeam-python/sunbeam/features/baremetal/steps.py +++ b/sunbeam-python/sunbeam/features/baremetal/steps.py @@ -58,6 +58,22 @@ def __init__( self.model = OPENSTACK_MODEL self.apps = apps + def is_skip(self, context: StepContext) -> Result: + """Skip the temp-url-secret action when Glance uses external S3. + + With an S3-backed Glance, ironic-conductor serves deploy images from the + S3 backend through its local HTTP server and does not rely on Ceph + RadosGW Swift temporary URLs, so no temp-url secret is required. + """ + from sunbeam.steps.openstack import is_glance_s3_storage_enabled + + if is_glance_s3_storage_enabled(self.deployment.get_client()): + return Result( + ResultType.SKIPPED, + "Glance uses external S3 storage; temp-url secret not required.", + ) + return Result(ResultType.COMPLETED) + def run(self, context: StepContext) -> Result: """Run the set-temp-url-secret action on ironic-conductor apps.""" try: diff --git a/sunbeam-python/sunbeam/steps/openstack.py b/sunbeam-python/sunbeam/steps/openstack.py index c3fbce5f0..a81206d24 100644 --- a/sunbeam-python/sunbeam/steps/openstack.py +++ b/sunbeam-python/sunbeam/steps/openstack.py @@ -38,6 +38,7 @@ JujuStepHelper, JujuWaitException, build_pre_status_overlay, + split_controller_from_offer_url, ) from sunbeam.core.k8s import CREDENTIAL_SUFFIX, K8SHelper from sunbeam.core.manifest import Manifest, check_storage_modifications_in_manifest @@ -78,6 +79,15 @@ DATABASE_STORAGE_KEY = "DatabaseStorage" DEFAULT_DATABASE_TOPOLOGY = "single" +# Manifest key (glance-k8s model_extra) for the s3-integrator offer URL. +# Format: ``[:][/].``. +GLANCE_S3_OFFER_URL_KEY = "s3-integrator-offer-url" + +# TF variables that select and wire the external S3 image backend. +ENABLE_GLANCE_S3_TFVAR = "enable-glance-s3-storage" +GLANCE_S3_OFFER_URL_TFVAR = "glance-s3-integrator-offer-url" +GLANCE_S3_OFFERING_CONTROLLER_TFVAR = "glance-s3-integrator-offering-controller" + DATABASE_MAX_POOL_SIZE = 2 DATABASE_ADDITIONAL_BUFFER_SIZE = 600 DATABASE_OVERSIZE_FACTOR = 1.2 @@ -92,6 +102,39 @@ APPS_BLOCKED_WHEN_FEATURE_ENABLED = ["barbican", "vault"] +def _glance_charm_extra(manifest: Manifest) -> dict | None: + """Return the model_extra dict for glance-k8s from the manifest.""" + charm_manifest = manifest.core.software.charms.get("glance-k8s") + if not charm_manifest or not charm_manifest.model_extra: + return None + if not isinstance(charm_manifest.model_extra, dict): + return None + return charm_manifest.model_extra + + +def get_glance_s3_offer(manifest: Manifest) -> str | None: + """Return the external s3-integrator offer from the manifest. + + Reads ``core.software.charms.glance-k8s.s3-integrator-offer-url``. + """ + extra = _glance_charm_extra(manifest) + if extra is None: + return None + url = extra.get(GLANCE_S3_OFFER_URL_KEY) + if not isinstance(url, str) or not url: + return None + return url + + +def is_glance_s3_storage_enabled(client: Client) -> bool: + """Whether the deployed control plane uses external S3 for Glance images.""" + try: + tfvars = read_config(client, CONFIG_KEY) + except ConfigItemNotFoundException: + return False + return bool(tfvars.get(ENABLE_GLANCE_S3_TFVAR, False)) + + def remove_blocked_apps_from_features(jhelper: JujuHelper, model: str) -> list[str]: """Apps that are in blocked state from features. @@ -606,6 +649,45 @@ def get_storage_tfvars(self, storage_nodes: list[dict]) -> dict: return tfvars + def get_glance_storage_tfvars(self) -> dict: + """Create terraform variables related to the Glance image storage backend. + + When ``s3-integrator-offer-url`` is empty, the Glance image backend will be + configured to use the local storage. + """ + tfvars: dict = {} + full_offer_url = get_glance_s3_offer(self.manifest) + + LOG.debug("Glance S3 offer URL from manifest: %s", full_offer_url) + + # Split the controller from the offer URL if it is present. + if full_offer_url: + controller, offer_url = split_controller_from_offer_url(full_offer_url) + else: + controller = None + offer_url = None + + tfvars.update( + { + ENABLE_GLANCE_S3_TFVAR: offer_url is not None, + GLANCE_S3_OFFER_URL_TFVAR: offer_url, + GLANCE_S3_OFFERING_CONTROLLER_TFVAR: controller, + } + ) + + # Check if the offering controller has been registered in Juju provider + if controller: + controller_config = self.get_controller_config(controller) + if not controller_config: + raise SunbeamException( + f"Glance S3 offering controller {controller} is not registered " + "in Juju provider" + ) + + tfvars.update({"remote-controllers": {controller: controller_config}}) + + return tfvars + def get_network_tfvars(self) -> dict: """Create terraform variables related to network.""" return self.ovn_manager.get_control_plane_tfvars(self.deployment, self.jhelper) @@ -766,6 +848,7 @@ def run(self, context: StepContext) -> Result: extra_tfvars = self.get_storage_tfvars(storage_nodes) extra_tfvars.update(self.get_network_tfvars()) extra_tfvars.update(self.get_region_tfvars()) + extra_tfvars.update(self.get_glance_storage_tfvars()) # This is used to calculate the "experimental-max-connections" mysql setting. extra_tfvars.update( diff --git a/sunbeam-python/tests/unit/sunbeam/features/baremetal/test_baremetal_steps.py b/sunbeam-python/tests/unit/sunbeam/features/baremetal/test_baremetal_steps.py index da6271b00..8ae340c15 100644 --- a/sunbeam-python/tests/unit/sunbeam/features/baremetal/test_baremetal_steps.py +++ b/sunbeam-python/tests/unit/sunbeam/features/baremetal/test_baremetal_steps.py @@ -71,6 +71,25 @@ def test_run_set_temp_url_secret_timeout(self, step_context): queue=ANY, ) + def test_temp_url_secret_skipped_when_glance_s3(self, deployment, step_context): + deployment.get_client.return_value._cluster_config["TerraformVarsOpenstack"] = { + "enable-glance-s3-storage": True + } + step = steps.RunSetTempUrlSecretStep(deployment, Mock()) + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.SKIPPED + + def test_temp_url_secret_not_skipped_without_glance_s3( + self, deployment, step_context + ): + step = steps.RunSetTempUrlSecretStep(deployment, Mock()) + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + def test_deploy_nova_ironic_shards_already_exists(self, deployment, step_context): ironic = ironic_feature.BaremetalFeature() ironic._manifest = Mock() diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_openstack.py b/sunbeam-python/tests/unit/sunbeam/steps/test_openstack.py index f4789df7d..5324139f6 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_openstack.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_openstack.py @@ -44,7 +44,9 @@ compute_os_api_scale, get_database_default_storage_dict, get_database_storage_dict, + get_glance_s3_offer, get_rabbitmq_storage_tfvars, + is_glance_s3_storage_enabled, remove_blocked_apps_from_features, remove_blocked_apps_from_ovn_provider, remove_blocked_apps_from_role, @@ -52,6 +54,7 @@ TOPOLOGY = "single" MODEL = "test-model" +GLANCE_S3_OFFER_URL = "admin/my-model.my-s3-integrator" # Additional fixtures specific to openstack tests @@ -164,6 +167,111 @@ def test_run_pristine_installation( basic_tfhelper.update_tfvars_and_apply_tf.assert_called_once() assert result.result_type == ResultType.COMPLETED + def test_get_glance_storage_tfvars_default( + self, + deployment_with_client, + basic_tfhelper, + basic_jhelper, + snap_patch, + snap_mock, + ): + snap_mock().config.get.return_value = "k8s" + manifest = Manifest(**{"core": {"software": {"charms": {"glance-k8s": {}}}}}) + step = DeployControlPlaneStep( + deployment_with_client, + basic_tfhelper, + basic_jhelper, + manifest, + TOPOLOGY, + MODEL, + ) + assert step.get_glance_storage_tfvars() == { + "enable-glance-s3-storage": False, + "glance-s3-integrator-offer-url": None, + "glance-s3-integrator-offering-controller": None, + } + + def test_get_remote_controllers_tfvars_with_s3( + self, + deployment_with_client, + basic_tfhelper, + basic_jhelper, + snap_patch, + snap_mock, + ): + """Offer URL with controller prefix -> remote_controllers populated.""" + snap_mock().config.get.return_value = "k8s" + manifest = Manifest( + **{ + "core": { + "software": { + "charms": { + "glance-k8s": { + "s3-integrator-offer-url": ( + "myctrl:admin/my-model.my-s3" + ), + "storage": {"local-repository": "5G"}, + } + } + } + } + } + ) + step = DeployControlPlaneStep( + deployment_with_client, + basic_tfhelper, + basic_jhelper, + manifest, + TOPOLOGY, + MODEL, + ) + + with patch.object( + step, + "get_controller_config", + return_value={ + "controller_addresses": "10.0.0.1:17070", + "username": "admin", + "password": "s3cret", + "ca_certificate": "FAKE-CERT", + }, + ): + tfvars = step.get_glance_storage_tfvars() + + assert tfvars["glance-s3-integrator-offering-controller"] == "myctrl" + assert tfvars["remote-controllers"] == { + "myctrl": { + "controller_addresses": "10.0.0.1:17070", + "username": "admin", + "password": "s3cret", + "ca_certificate": "FAKE-CERT", + } + } + + def test_get_remote_controllers_tfvars_empty( + self, + deployment_with_client, + basic_tfhelper, + basic_jhelper, + snap_patch, + snap_mock, + ): + """No controller prefix in offer URL -> empty remote_controllers.""" + snap_mock().config.get.return_value = "k8s" + manifest = Manifest(**{"core": {"software": {"charms": {"glance-k8s": {}}}}}) + step = DeployControlPlaneStep( + deployment_with_client, + basic_tfhelper, + basic_jhelper, + manifest, + TOPOLOGY, + MODEL, + ) + tfvars = step.get_glance_storage_tfvars() + assert tfvars["glance-s3-integrator-offering-controller"] is None + + assert "remote-controllers" not in tfvars + def test_run_tf_apply_failed( self, deployment_with_client, @@ -364,6 +472,61 @@ def test_is_skip_fails_on_storage_modification( assert "rabbitmq-storage" in result.message +def _glance_manifest( + s3_offer_url: str | None = None, + storage: dict | None = None, +) -> Manifest: + """Build a Manifest with optional s3-integrator-offer-url and/or storage.""" + charm: dict = {} + if s3_offer_url is not None: + charm["s3-integrator-offer-url"] = s3_offer_url + if storage is not None: + charm["storage"] = storage + return Manifest( + **{ + "core": { + "software": { + "charms": {"glance-k8s": charm}, + } + } + } + ) + + +class TestGetGlanceS3OfferUrl: + def test_returns_url_when_declared(self): + manifest = _glance_manifest(s3_offer_url=GLANCE_S3_OFFER_URL) + assert get_glance_s3_offer(manifest) == GLANCE_S3_OFFER_URL + + def test_returns_none_when_not_set(self): + assert get_glance_s3_offer(_glance_manifest()) is None + + def test_returns_none_when_empty(self): + manifest = _glance_manifest(s3_offer_url="") + assert get_glance_s3_offer(manifest) is None + + def test_returns_none_when_charm_absent(self): + manifest = Manifest(**{"core": {"software": {"charms": {}}}}) + assert get_glance_s3_offer(manifest) is None + + +class TestIsGlanceS3StorageEnabledUnit: + def test_true_when_tfvar_set(self): + client = Mock() + client.cluster.get_config.return_value = '{"enable-glance-s3-storage": true}' + assert is_glance_s3_storage_enabled(client) is True + + def test_false_when_tfvar_absent(self): + client = Mock() + client.cluster.get_config.return_value = '{"foo": "bar"}' + assert is_glance_s3_storage_enabled(client) is False + + def test_false_when_config_missing(self): + client = Mock() + client.cluster.get_config.side_effect = ConfigItemNotFoundException + assert is_glance_s3_storage_enabled(client) is False + + @pytest.fixture def ovn_manager(): """Ovn manager mock."""