diff --git a/snap-wrappers/completions/generate_completion_cache.py b/snap-wrappers/completions/generate_completion_cache.py index 1693b9e39..7b17803e7 100644 --- a/snap-wrappers/completions/generate_completion_cache.py +++ b/snap-wrappers/completions/generate_completion_cache.py @@ -12,7 +12,7 @@ import sys from sunbeam.commands import configure as configure_cmds -from sunbeam.commands import dashboard_url as dashboard_url_cmds +from sunbeam.commands import dashboard as dashboard_cmds from sunbeam.commands import generate_cloud_config as generate_cloud_config_cmds from sunbeam.commands import juju_utils as juju_cmds from sunbeam.commands import launch as launch_cmds @@ -95,7 +95,7 @@ def register_commands(): cli.add_command(generate_cloud_config_cmds.cloud_config) cli.add_command(launch_cmds.launch) cli.add_command(openrc_cmds.openrc) - cli.add_command(dashboard_url_cmds.dashboard_url) + cli.add_command(dashboard_cmds.dashboard) cli.add_command(identity_group) identity_group.add_command(provider_group) identity_group.add_command(sso_cmd.set_saml_x509) diff --git a/sunbeam-python/sunbeam/commands/dashboard.py b/sunbeam-python/sunbeam/commands/dashboard.py new file mode 100644 index 000000000..08f06f550 --- /dev/null +++ b/sunbeam-python/sunbeam/commands/dashboard.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import click +from rich.console import Console + +from sunbeam.core import juju +from sunbeam.core.checks import ( + JujuLoginCheck, + VerifyBootstrappedCheck, + run_preflight_checks, +) +from sunbeam.core.common import ( + PromptMode, + run_plan, +) +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import JujuHelper +from sunbeam.core.openstack import OPENSTACK_MODEL +from sunbeam.core.questions import write_answers +from sunbeam.core.terraform import TerraformInitStep +from sunbeam.steps.horizon import THEME_CONFIG_SECTION, AttachHorizonThemeStep +from sunbeam.utils import click_option_show_hints + +LOG = logging.getLogger(__name__) +console = Console() + + +def retrieve_dashboard_url(jhelper: juju.JujuHelper) -> str: + """Retrieve dashboard URL from Horizon service.""" + model = OPENSTACK_MODEL + app = "horizon" + action_cmd = "get-dashboard-url" + try: + unit = jhelper.get_leader_unit(app, model) + except juju.LeaderNotFoundException: + raise ValueError(f"Unable to get {app} leader") + try: + action_result = jhelper.run_action(unit, model, action_cmd) + except juju.ActionFailedException: + _message = "Unable to retrieve URL from Horizon service" + raise ValueError(_message) + return action_result["url"] + + +@click.group() +@click.pass_context +def dashboard(ctx: click.Context) -> None: + """Manage OpenStack Dashboard.""" + + +@dashboard.command("url") +@click.pass_context +def dashboard_url(ctx: click.Context) -> None: + """Retrieve OpenStack Dashboard URL.""" + deployment: Deployment = ctx.obj + preflight_checks = [ + VerifyBootstrappedCheck(deployment.get_client()), + JujuLoginCheck(deployment.juju_account), + ] + run_preflight_checks(preflight_checks, console) + + jhelper = juju.JujuHelper(deployment.juju_controller) + + with console.status("Retrieving dashboard URL from Horizon service ... "): + try: + console.print(retrieve_dashboard_url(jhelper)) + except Exception as e: + raise click.ClickException(str(e)) + + +@click.group() +@click.pass_context +def theme(ctx: click.Context) -> None: + """Manage Horizon themes.""" + + +dashboard.add_command(theme) + + +@theme.command("set") +@click_option_show_hints +@click.pass_context +def set_theme(ctx: click.Context, show_hints: bool) -> None: + """Set a custom Horizon theme interactively.""" + deployment: Deployment = ctx.obj + client = deployment.get_client() + jhelper = JujuHelper(deployment.juju_controller) + manifest = deployment.get_manifest() + tfhelper = deployment.get_tfhelper("openstack-plan") + + plan = [ + TerraformInitStep(tfhelper), + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=tfhelper, + manifest=manifest, + model=OPENSTACK_MODEL, + prompt_mode=PromptMode.FORCE, + ), + ] + run_plan(plan, console, show_hints) + console.print("Custom theme applied.") + + +@theme.command("clear") +@click_option_show_hints +@click.pass_context +def clear_theme(ctx: click.Context, show_hints: bool) -> None: + """Clear custom Horizon theme and restore defaults.""" + deployment: Deployment = ctx.obj + client = deployment.get_client() + jhelper = JujuHelper(deployment.juju_controller) + manifest = deployment.get_manifest() + tfhelper = deployment.get_tfhelper("openstack-plan") + + write_answers(client, THEME_CONFIG_SECTION, {"enable_custom_theme": False}) + + plan = [ + TerraformInitStep(tfhelper), + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=tfhelper, + manifest=manifest, + model=OPENSTACK_MODEL, + prompt_mode=PromptMode.NEVER, + ), + ] + run_plan(plan, console, show_hints) + console.print("Custom theme cleared.") diff --git a/sunbeam-python/sunbeam/commands/dashboard_url.py b/sunbeam-python/sunbeam/commands/dashboard_url.py deleted file mode 100644 index d08b259b6..000000000 --- a/sunbeam-python/sunbeam/commands/dashboard_url.py +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: 2022 - Canonical Ltd -# SPDX-License-Identifier: Apache-2.0 - -import logging - -import click -from rich.console import Console - -from sunbeam.core import juju -from sunbeam.core.checks import ( - JujuLoginCheck, - VerifyBootstrappedCheck, - run_preflight_checks, -) -from sunbeam.core.deployment import Deployment -from sunbeam.core.openstack import OPENSTACK_MODEL - -LOG = logging.getLogger(__name__) -console = Console() - - -def retrieve_dashboard_url(jhelper: juju.JujuHelper) -> str: - """Retrieve dashboard URL from Horizon service.""" - model = OPENSTACK_MODEL - app = "horizon" - action_cmd = "get-dashboard-url" - try: - unit = jhelper.get_leader_unit(app, model) - except juju.LeaderNotFoundException: - raise ValueError(f"Unable to get {app} leader") - try: - action_result = jhelper.run_action(unit, model, action_cmd) - except juju.ActionFailedException: - _message = "Unable to retrieve URL from Horizon service" - raise ValueError(_message) - return action_result["url"] - - -@click.command() -@click.pass_context -def dashboard_url(ctx: click.Context) -> None: - """Retrieve OpenStack Dashboard URL.""" - deployment: Deployment = ctx.obj - preflight_checks = [ - VerifyBootstrappedCheck(deployment.get_client()), - JujuLoginCheck(deployment.juju_account), - ] - run_preflight_checks(preflight_checks, console) - - jhelper = juju.JujuHelper(deployment.juju_controller) - - with console.status("Retrieving dashboard URL from Horizon service ... "): - try: - console.print(retrieve_dashboard_url(jhelper)) - except Exception as e: - raise click.ClickException(str(e)) diff --git a/sunbeam-python/sunbeam/core/common.py b/sunbeam-python/sunbeam/core/common.py index 92b4218d1..ebdf721c2 100644 --- a/sunbeam-python/sunbeam/core/common.py +++ b/sunbeam-python/sunbeam/core/common.py @@ -232,6 +232,14 @@ def __init__(self, result_type: ResultType = ResultType.COMPLETED, **kwargs): self.__setattr__(key, value) +class PromptMode(enum.Enum): + """Controls whether a step prompts the user interactively.""" + + AUTO = "auto" + FORCE = "force" + NEVER = "never" + + @dataclass class StepContext: """Cross-cutting concerns passed to every step during plan execution.""" diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index 2c4e6dc4c..6110bc564 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -364,6 +364,35 @@ def destroy_model( except ModelNotFoundException: LOG.debug("Model %s not found", model) + def attach_resource( + self, + model: str, + application: str, + resource: str, + filepath: str, + ): + """Attach a file resource to a juju application. + + :model: Name of the model + :application: Name of the application + :resource: Name of the resource + :filepath: Local filepath to the file to be attached + :returns: Revision number of the attached resource + """ + try: + with self._model(model): + self.cli( + "attach-resource", + application, + f"{resource}={filepath}", + json_format=False, + include_controller=False, + ) + except jubilant.CLIError as e: + raise JujuException( + f"Failed to attach resource {resource} to {application}: {str(e)}" + ) + def integrate( self, model: str, diff --git a/sunbeam-python/sunbeam/core/manifest.py b/sunbeam-python/sunbeam/core/manifest.py index 4f072b9a7..69f7927b1 100644 --- a/sunbeam-python/sunbeam/core/manifest.py +++ b/sunbeam-python/sunbeam/core/manifest.py @@ -261,6 +261,17 @@ class _PCI(pydantic.BaseModel): # Excluded PCI addresses per node. excluded_devices: dict[str, list[str]] | None = None + class _HorizonConfig(pydantic.BaseModel): + class _Resources(pydantic.BaseModel): + custom_theme: Path | None = None + + enable_custom_theme: bool | None = None + custom_theme_name: str | None = None + theme_path: str | None = None + disable_default_themes: bool | None = None + disable_ubuntu_theme: bool | None = None + resources: _Resources | None = None + class _Endpoints(pydantic.BaseModel): class _Endpoint(pydantic.BaseModel): hostname: str | None = None @@ -304,6 +315,7 @@ class _DPDK(pydantic.BaseModel): ) microceph_config: pydantic.RootModel[dict[str, _HostMicroCephConfig]] | None = None pci: _PCI | None = None + horizon: _HorizonConfig | None = None dpdk: _DPDK | None = None diff --git a/sunbeam-python/sunbeam/main.py b/sunbeam-python/sunbeam/main.py index ef6d4866e..69efba9d3 100644 --- a/sunbeam-python/sunbeam/main.py +++ b/sunbeam-python/sunbeam/main.py @@ -10,7 +10,7 @@ from sunbeam import log from sunbeam.commands import configure as configure_cmds -from sunbeam.commands import dashboard_url as dasboard_url_cmds +from sunbeam.commands import dashboard as dashboard_cmds from sunbeam.commands import generate_cloud_config as generate_cloud_config_cmds from sunbeam.commands import juju_utils as juju_cmds from sunbeam.commands import launch as launch_cmds @@ -122,7 +122,7 @@ def main(): cli.add_command(generate_cloud_config_cmds.cloud_config) cli.add_command(launch_cmds.launch) cli.add_command(openrc_cmds.openrc) - cli.add_command(dasboard_url_cmds.dashboard_url) + cli.add_command(dashboard_cmds.dashboard) # Add identity group cli.add_command(identity_group) diff --git a/sunbeam-python/sunbeam/provider/local/commands.py b/sunbeam-python/sunbeam/provider/local/commands.py index a277fe46e..314ccb3db 100644 --- a/sunbeam-python/sunbeam/provider/local/commands.py +++ b/sunbeam-python/sunbeam/provider/local/commands.py @@ -26,7 +26,7 @@ UserOpenRCStep, retrieve_admin_credentials, ) -from sunbeam.commands.dashboard_url import retrieve_dashboard_url +from sunbeam.commands.dashboard import retrieve_dashboard_url from sunbeam.commands.proxy import PromptForProxyStep from sunbeam.core import ovn from sunbeam.core.checks import ( @@ -117,6 +117,7 @@ PromptCheckNodeExistStep, SaveManagementCidrStep, ) +from sunbeam.steps.horizon import AttachHorizonThemeStep from sunbeam.steps.hypervisor import ( DeployHypervisorApplicationStep, ReapplyHypervisorOptionalIntegrationsStep, @@ -906,6 +907,15 @@ def bootstrap( # noqa: C901 is_region_controller=is_region_controller, ) ) + plan1.append( + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=openstack_tfhelper, + manifest=manifest, + model=OPENSTACK_MODEL, + ) + ) plan1.append( SetKeystoneSAMLCertAndKeyStep( deployment=deployment, diff --git a/sunbeam-python/sunbeam/provider/maas/commands.py b/sunbeam-python/sunbeam/provider/maas/commands.py index 15f028a49..f5b3b9321 100644 --- a/sunbeam-python/sunbeam/provider/maas/commands.py +++ b/sunbeam-python/sunbeam/provider/maas/commands.py @@ -25,7 +25,7 @@ UserOpenRCStep, retrieve_admin_credentials, ) -from sunbeam.commands.dashboard_url import retrieve_dashboard_url +from sunbeam.commands.dashboard import retrieve_dashboard_url from sunbeam.commands.proxy import PromptForProxyStep from sunbeam.core import ovn from sunbeam.core.checks import ( @@ -134,6 +134,7 @@ DeploySunbeamClusterdApplicationStep, ) from sunbeam.steps.features import DisableEnabledFeatures +from sunbeam.steps.horizon import AttachHorizonThemeStep from sunbeam.steps.hypervisor import ( DeployHypervisorApplicationStep, DestroyHypervisorApplicationStep, @@ -854,6 +855,15 @@ def deploy( is_region_controller=bool(nb_region_controllers), ) ) + plan2.append( + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=tfhelper_openstack_deploy, + manifest=manifest, + model=OPENSTACK_MODEL, + ) + ) if microovn_necessary: plan2.append( ReapplyMicroOVNOptionalIntegrationsStep( diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py new file mode 100644 index 000000000..629d758d5 --- /dev/null +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -0,0 +1,267 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import logging +import tarfile +from pathlib import Path + +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.core.common import ( + BaseStep, + PromptMode, + Result, + ResultType, + StepContext, + read_config, +) +from sunbeam.core.juju import JujuException, JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.questions import ( + ConfirmQuestion, + PromptQuestion, + QuestionBank, + load_answers, + write_answers, +) +from sunbeam.core.terraform import ( + TerraformException, + TerraformHelper, + TerraformStateLockedException, +) +from sunbeam.steps.openstack import CONFIG_KEY as OPENSTACK_TFVAR_CONFIG_KEY + +LOG = logging.getLogger(__name__) +THEME_CONFIG_SECTION = "Horizon" + + +def _validate_theme_path(path_str: str): + """Validate path is a non-empty .tar.gz archive and contains a valid theme.""" + if not path_str: + raise ValueError("Theme path is required") + + p = Path(path_str) + if not p.is_file(): + raise ValueError(f"Theme file does not exist: {path_str}") + if not tarfile.is_tarfile(p): + raise ValueError(f"Theme file is not a valid tarball: {path_str}") + + +class AttachHorizonThemeStep(BaseStep): + """Prompt for and configure custom theme resources for Horizon.""" + + def __init__( + self, + client, + jhelper: JujuHelper, + tfhelper: TerraformHelper, + manifest: Manifest, + model: str, + accept_defaults: bool = False, + prompt_mode: PromptMode = PromptMode.AUTO, + ): + super().__init__("Configure Horizon Themes", "Configuring themes for Horizon") + self.client = client + self.jhelper = jhelper + self.tfhelper = tfhelper + self.manifest = manifest + self.model = model + self.accept_defaults = accept_defaults + self.prompt_mode = prompt_mode + self.variables: dict = {} + + def _get_horizon_config_from_manifest(self) -> dict: + if not self.manifest or not self.manifest.core.config.horizon: + return {} + base = self.manifest.core.config.horizon + cfg = base.dict(exclude_none=True, exclude={"resources"}) + if base.resources and base.resources.custom_theme: + cfg["theme_path"] = str(base.resources.custom_theme) + return cfg + + def has_prompts(self) -> bool: + """Indicate that this step requires interactive user input.""" + if self.prompt_mode == PromptMode.NEVER: + return False + if self.prompt_mode == PromptMode.FORCE: + return True + + manifest_cfg = self._get_horizon_config_from_manifest() + stored = load_answers(self.client, THEME_CONFIG_SECTION) + if "enable_custom_theme" in manifest_cfg: + return False + if "enable_custom_theme" in stored: + return False + return True + + def prompt(self, console=None, show_hint=False) -> None: + """Execute the interactive prompts dynamically.""" + self.variables = load_answers(self.client, THEME_CONFIG_SECTION) + manifest_cfg = self._get_horizon_config_from_manifest() + self.variables.update(manifest_cfg) + + enable_bank = QuestionBank( + questions={ + "enable_custom_theme": ConfirmQuestion( + "Customize available Horizon themes?", + default_value=False, + description=( + "Enables custom theming as well as controls built-in themes" + ), + ) + }, + console=console, + previous_answers=self.variables, + show_hint=show_hint, + accept_defaults=self.accept_defaults, + ) + enable = enable_bank.enable_custom_theme.ask() + self.variables["enable_custom_theme"] = enable + + if enable: + details_bank = QuestionBank( + questions={ + "custom_theme_name": PromptQuestion( + "Custom theme name", + default_value="custom", + description=( + "Name that will be used for the theme folder " + "as well as displayed in GUI" + ), + ), + "theme_path": PromptQuestion( + "Custom theme archive path", + default_value="", + description=( + "Local filepath to a tarball (.tar.gz) created" + "at the root of your theme" + ), + validation_function=_validate_theme_path, + ), + "disable_default_themes": ConfirmQuestion( + "Disable default openstack themes", + default_value=False, + description=( + "Disables default and material themes " + "included by upstream OpenStack" + ), + ), + "disable_ubuntu_theme": ConfirmQuestion( + "Disable included ubuntu theme", + default_value=False, + description=( + "Disables included Ubuntu theme (the Sunbeam default theme)" + ), + ), + }, + console=console, + previous_answers=self.variables, + show_hint=show_hint, + accept_defaults=self.accept_defaults, + ) + self.variables["custom_theme_name"] = details_bank.custom_theme_name.ask() + self.variables["theme_path"] = details_bank.theme_path.ask() + self.variables["disable_default_themes"] = ( + details_bank.disable_default_themes.ask() + ) + self.variables["disable_ubuntu_theme"] = ( + details_bank.disable_ubuntu_theme.ask() + ) + + if not self.variables["disable_ubuntu_theme"]: + def_theme = "ubuntu" + elif not self.variables["disable_default_themes"]: + def_theme = "default" + else: + def_theme = self.variables["custom_theme_name"] + + default_theme_bank = QuestionBank( + questions={ + "default_theme": PromptQuestion( + "Default theme", + default_value=def_theme, + description="Theme to be selected by default in the UI", + ) + }, + console=console, + previous_answers=self.variables, + show_hint=show_hint, + accept_defaults=self.accept_defaults, + ) + self.variables["default_theme"] = default_theme_bank.default_theme.ask() + + write_answers(self.client, THEME_CONFIG_SECTION, self.variables) + + def run(self, context: StepContext) -> Result: + """Attach the resource and push the configuration via Terraform.""" + if not self.variables: + stored = load_answers(self.client, THEME_CONFIG_SECTION) + manifest_cfg = self._get_horizon_config_from_manifest() + self.variables = {**stored, **manifest_cfg} + + if self.variables.get("enable_custom_theme"): + theme_path = Path(self.variables.get("theme_path", "")) + if not theme_path.exists() or not theme_path.is_file(): + return Result( + ResultType.FAILED, f"Theme file {theme_path} is invalid or missing." + ) + try: + self.jhelper.attach_resource( + application="horizon", + model=self.model, + resource="custom-theme", + filepath=str(theme_path), + ) + except JujuException as e: + LOG.exception("Failed to attach horizon theme resource") + return Result( + ResultType.FAILED, + f"failed to attach resource {str(theme_path)}: {str(e)}", + ) + + horizon_config = { + "include-default-themes": not self.variables.get( + "disable_default_themes", False + ), + "include-ubuntu-theme": not self.variables.get( + "disable_ubuntu_theme", False + ), + "default-theme": self.variables.get("default_theme", "ubuntu"), + "custom-theme-name": self.variables.get("custom_theme_name", "custom"), + } + else: + horizon_config = { + "include-default-themes": True, + "include-ubuntu-theme": True, + "default-theme": "ubuntu", + "custom-theme-name": None, + } + + try: + current_tfvars = read_config(self.client, OPENSTACK_TFVAR_CONFIG_KEY) + except ConfigItemNotFoundException: + current_tfvars = {} + except Exception as e: + LOG.exception("Error reading current tfvars") + return Result(ResultType.FAILED, f"failed to read tfvars: {str(e)}") + + merged_config = {**current_tfvars.get("horizon-config", {}), **horizon_config} + + override_tfvars = { + "horizon-config": merged_config, + } + + try: + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=OPENSTACK_TFVAR_CONFIG_KEY, + override_tfvars=override_tfvars, + reporter=context.reporter, + ) + except (TerraformException, TerraformStateLockedException) as e: + LOG.exception("Failed to update tfvars") + return Result( + ResultType.FAILED, + f"failed to update tfvars: {str(e)}", + ) + return Result(ResultType.COMPLETED) diff --git a/sunbeam-python/sunbeam/versions.py b/sunbeam-python/sunbeam/versions.py index fabc3725a..c34aee3cf 100644 --- a/sunbeam-python/sunbeam/versions.py +++ b/sunbeam-python/sunbeam/versions.py @@ -178,6 +178,7 @@ class VarMap(TypedDict, total=False): "channel": f"{charm.removesuffix('-k8s')}-channel", "revision": f"{charm.removesuffix('-k8s')}-revision", "config": f"{charm.removesuffix('-k8s')}-config", + "resources": f"{charm.removesuffix('-k8s')}-resources", } for charm, channel in K8S_CHARMS.items() }, diff --git a/sunbeam-python/tests/unit/sunbeam/commands/test_dashboard.py b/sunbeam-python/tests/unit/sunbeam/commands/test_dashboard.py new file mode 100644 index 000000000..28c86952f --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/commands/test_dashboard.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +from sunbeam.commands.dashboard import ( + clear_theme, + dashboard_url, + retrieve_dashboard_url, + set_theme, +) +from sunbeam.core import juju as juju_module +from sunbeam.core.common import PromptMode +from sunbeam.steps.horizon import THEME_CONFIG_SECTION + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def deployment(): + d = Mock() + d.get_client.return_value = Mock() + d.juju_controller = Mock() + d.get_manifest.return_value = Mock() + d.get_tfhelper.return_value = Mock() + return d + + +@pytest.fixture +def jhelper_patch(): + with patch("sunbeam.commands.dashboard.JujuHelper") as p: + yield p + + +@pytest.fixture +def run_plan_patch(): + with patch("sunbeam.commands.dashboard.run_plan") as p: + yield p + + +@pytest.fixture +def terraform_init_patch(): + with patch("sunbeam.commands.dashboard.TerraformInitStep") as p: + yield p + + +@pytest.fixture +def attach_step_patch(): + with patch("sunbeam.commands.dashboard.AttachHorizonThemeStep") as p: + yield p + + +@pytest.fixture +def write_answers_patch(): + with patch("sunbeam.commands.dashboard.write_answers") as p: + yield p + + +def test_retrieve_dashboard_url_returns_url(): + jhelper = Mock() + jhelper.get_leader_unit.return_value = "horizon/0" + jhelper.run_action.return_value = {"url": "https://horizon.example.com"} + assert retrieve_dashboard_url(jhelper) == "https://horizon.example.com" + + +def test_retrieve_dashboard_url_leader_not_found(): + jhelper = Mock() + jhelper.get_leader_unit.side_effect = juju_module.LeaderNotFoundException( + "no leader" + ) + with pytest.raises(ValueError, match="Unable to get horizon leader"): + retrieve_dashboard_url(jhelper) + + +def test_retrieve_dashboard_url_action_failed(): + jhelper = Mock() + jhelper.get_leader_unit.return_value = "horizon/0" + jhelper.run_action.side_effect = juju_module.ActionFailedException("nope") + with pytest.raises(ValueError, match="Unable to retrieve URL"): + retrieve_dashboard_url(jhelper) + + +def test_set_theme_runs_step_with_force_prompt( + runner, + deployment, + jhelper_patch, + run_plan_patch, + terraform_init_patch, + attach_step_patch, +): + result = runner.invoke(set_theme, obj=deployment) + assert result.exit_code == 0 + attach_step_patch.assert_called_once() + assert attach_step_patch.call_args.kwargs["prompt_mode"] == PromptMode.FORCE + run_plan_patch.assert_called_once() + + +def test_clear_theme_writes_disable_answer_and_runs_step( + runner, + deployment, + jhelper_patch, + run_plan_patch, + terraform_init_patch, + attach_step_patch, + write_answers_patch, +): + result = runner.invoke(clear_theme, obj=deployment) + assert result.exit_code == 0 + write_answers_patch.assert_called_once_with( + deployment.get_client.return_value, + THEME_CONFIG_SECTION, + {"enable_custom_theme": False}, + ) + assert attach_step_patch.call_args.kwargs["prompt_mode"] == PromptMode.NEVER + + +@patch("sunbeam.commands.dashboard.run_preflight_checks") +def test_dashboard_url_prints_url(preflight_patch, runner, deployment, jhelper_patch): + with patch( + "sunbeam.commands.dashboard.retrieve_dashboard_url", + return_value="https://horizon.example.com", + ): + result = runner.invoke(dashboard_url, obj=deployment) + assert result.exit_code == 0 + assert "horizon.example.com" in result.output diff --git a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py index 7a248e4e7..2ea65d804 100644 --- a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py +++ b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py @@ -1277,6 +1277,19 @@ def test_get_relation_map_no_related_units(jhelper, status): jhelper.get_relation_map("app", "certificates", "test-model") +def test_attach_resource_cli_error_wrapped(jhelper): + jhelper._juju.cli.side_effect = jubilant.CLIError( + 1, "attach-resource", stderr="bad file" + ) + with pytest.raises(jujulib.JujuException, match="Failed to attach"): + jhelper.attach_resource( + model="test-model", + application="horizon", + resource="custom-theme", + filepath="/tmp/theme.tar.gz", + ) + + # --------------------------------------------------------------------------- # build_pre_status_overlay # --------------------------------------------------------------------------- diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py b/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py new file mode 100644 index 000000000..a5e4cea6e --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py @@ -0,0 +1,328 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import tarfile +from unittest.mock import Mock, patch + +import pytest + +from sunbeam.clusterd.service import ConfigItemNotFoundException +from sunbeam.core.common import PromptMode, ResultType +from sunbeam.core.juju import JujuException +from sunbeam.core.terraform import TerraformException +from sunbeam.steps.horizon import AttachHorizonThemeStep + + +def _make_tar(tmp_path, name="theme.tar.gz"): + """Create a real tarball so tarfile.is_tarfile() succeeds.""" + inner = tmp_path / "dummy.txt" + inner.write_text("hello") + tar_path = tmp_path / name + with tarfile.open(tar_path, "w:gz") as tf: + tf.add(inner, arcname="dummy.txt") + return tar_path + + +@pytest.fixture +def client(): + return Mock() + + +@pytest.fixture +def jhelper(): + return Mock() + + +@pytest.fixture +def tfhelper(): + return Mock() + + +@pytest.fixture +def manifest_empty(): + m = Mock() + m.core.config.horizon = None + return m + + +@pytest.fixture +def manifest_with_theme(tmp_path): + theme_file = _make_tar(tmp_path) + horizon_cfg = Mock() + horizon_cfg.dict.return_value = { + "enable_custom_theme": True, + "custom_theme_name": "test-theme", + "default_theme": "test-theme", + "disable_default_themes": False, + "disable_ubuntu_theme": False, + } + horizon_cfg.resources = Mock(custom_theme=theme_file) + m = Mock() + m.core.config.horizon = horizon_cfg + return m, theme_file + + +@pytest.fixture +def load_answers_patch(): + with patch("sunbeam.steps.horizon.load_answers") as p: + p.return_value = {} + yield p + + +@pytest.fixture +def write_answers_patch(): + with patch("sunbeam.steps.horizon.write_answers") as p: + yield p + + +@pytest.fixture +def read_config_patch(): + """Patch read_config so the merge step gets a real dict.""" + with patch("sunbeam.steps.horizon.read_config") as p: + p.return_value = {} + yield p + + +def _make_step(client, jhelper, tfhelper, manifest, prompt_mode=PromptMode.AUTO): + return AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=tfhelper, + manifest=manifest, + model="openstack", + prompt_mode=prompt_mode, + ) + + +def test_run_no_custom_theme_applies_defaults( + client, + jhelper, + tfhelper, + manifest_empty, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + step = _make_step(client, jhelper, tfhelper, manifest_empty) + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + jhelper.attach_resource.assert_not_called() + override = tfhelper.update_tfvars_and_apply_tf.call_args.kwargs["override_tfvars"] + assert override["horizon-config"]["include-default-themes"] is True + assert override["horizon-config"]["include-ubuntu-theme"] is True + assert override["horizon-config"]["default-theme"] == "ubuntu" + + +def test_run_with_manifest_theme_attaches_and_applies( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + manifest, theme_file = manifest_with_theme + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + jhelper.attach_resource.assert_called_once_with( + application="horizon", + model="openstack", + resource="custom-theme", + filepath=str(theme_file), + ) + override = tfhelper.update_tfvars_and_apply_tf.call_args.kwargs["override_tfvars"] + assert override["horizon-config"]["custom-theme-name"] == "test-theme" + assert override["horizon-config"]["default-theme"] == "test-theme" + + +def test_run_missing_theme_path_fails( + client, + jhelper, + tfhelper, + manifest_empty, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + load_answers_patch.return_value = {"enable_custom_theme": True} + step = _make_step(client, jhelper, tfhelper, manifest_empty) + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + jhelper.attach_resource.assert_not_called() + + +def test_run_nonexistent_theme_path_fails( + client, + jhelper, + tfhelper, + manifest_empty, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + load_answers_patch.return_value = { + "enable_custom_theme": True, + "theme_path": "/nonexistent/theme.tar.gz", + } + step = _make_step(client, jhelper, tfhelper, manifest_empty) + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + assert "invalid or missing" in result.message + + +def test_run_attach_resource_failure( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + manifest, _ = manifest_with_theme + jhelper.attach_resource.side_effect = JujuException("juju is sad") + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + assert "failed to attach resource" in result.message + tfhelper.update_tfvars_and_apply_tf.assert_not_called() + + +def test_run_terraform_failure( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + manifest, _ = manifest_with_theme + tfhelper.update_tfvars_and_apply_tf.side_effect = TerraformException("boom") + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + assert "failed to update tfvars" in result.message + + +def test_run_read_config_not_found_uses_empty_dict( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + manifest, _ = manifest_with_theme + read_config_patch.side_effect = ConfigItemNotFoundException("none") + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + + +def test_run_merges_with_existing_tfvars( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + """Existing horizon-config values are preserved, new ones override.""" + manifest, _ = manifest_with_theme + read_config_patch.return_value = { + "horizon-config": { + "debug": "true", + "custom-theme-name": "old-theme", + }, + } + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + override = tfhelper.update_tfvars_and_apply_tf.call_args.kwargs["override_tfvars"] + # Preserved + assert override["horizon-config"]["debug"] == "true" + # Overridden + assert override["horizon-config"]["custom-theme-name"] == "test-theme" + + +def test_has_prompts_force_mode(client, jhelper, tfhelper, manifest_empty): + step = _make_step( + client, jhelper, tfhelper, manifest_empty, prompt_mode=PromptMode.FORCE + ) + assert step.has_prompts() is True + + +def test_has_prompts_never_mode(client, jhelper, tfhelper, manifest_empty): + step = _make_step( + client, jhelper, tfhelper, manifest_empty, prompt_mode=PromptMode.NEVER + ) + assert step.has_prompts() is False + + +def test_has_prompts_auto_with_manifest( + client, jhelper, tfhelper, manifest_with_theme, load_answers_patch +): + manifest, _ = manifest_with_theme + step = _make_step(client, jhelper, tfhelper, manifest) + assert step.has_prompts() is False + + +def test_has_prompts_auto_with_stored( + client, jhelper, tfhelper, manifest_empty, load_answers_patch +): + load_answers_patch.return_value = {"enable_custom_theme": False} + step = _make_step(client, jhelper, tfhelper, manifest_empty) + assert step.has_prompts() is False + + +def test_has_prompts_auto_no_data( + client, jhelper, tfhelper, manifest_empty, load_answers_patch +): + step = _make_step(client, jhelper, tfhelper, manifest_empty) + assert step.has_prompts() is True + + +def test_manifest_overrides_stored_answers( + client, + jhelper, + tfhelper, + manifest_with_theme, + load_answers_patch, + write_answers_patch, + read_config_patch, + step_context, +): + """Manifest values take priority over stored answers on conflict.""" + manifest, _ = manifest_with_theme + load_answers_patch.return_value = { + "enable_custom_theme": True, + "custom_theme_name": "stale-theme", + "default_theme": "stale-theme", + } + step = _make_step(client, jhelper, tfhelper, manifest) + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + override = tfhelper.update_tfvars_and_apply_tf.call_args.kwargs["override_tfvars"] + assert override["horizon-config"]["custom-theme-name"] == "test-theme"