From 9960b8f489577ce41c806ea23a0c8b24e2050b93 Mon Sep 17 00:00:00 2001 From: guno327 Date: Fri, 22 May 2026 16:40:46 -0400 Subject: [PATCH 01/11] feat: add horizon themeing support to configure --- sunbeam-python/sunbeam/core/juju.py | 28 +++ .../sunbeam/provider/local/commands.py | 14 ++ .../sunbeam/provider/maas/commands.py | 10 + sunbeam-python/sunbeam/steps/horizon.py | 188 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 sunbeam-python/sunbeam/steps/horizon.py diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index 2c4e6dc4c..a11df2dd1 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -364,6 +364,34 @@ 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 + """ + 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}: {e.stderr}" + ) + def integrate( self, model: str, diff --git a/sunbeam-python/sunbeam/provider/local/commands.py b/sunbeam-python/sunbeam/provider/local/commands.py index a277fe46e..d224ebc36 100644 --- a/sunbeam-python/sunbeam/provider/local/commands.py +++ b/sunbeam-python/sunbeam/provider/local/commands.py @@ -117,6 +117,7 @@ PromptCheckNodeExistStep, SaveManagementCidrStep, ) +from sunbeam.steps.horizon import AttachHorizonThemeStep from sunbeam.steps.hypervisor import ( DeployHypervisorApplicationStep, ReapplyHypervisorOptionalIntegrationsStep, @@ -2120,6 +2121,19 @@ def configure_cmd( ) ) + if "control" in node["role"]: + openstack_tfhelper = deployment.get_tfhelper("openstack-plan") + plan.append(TerraformInitStep(openstack_tfhelper)) + plan.append( + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=openstack_tfhelper, + manifest=manifest, + model=OPENSTACK_MODEL, + ) + ) + run_plan(plan, console, show_hints) dashboard_url = retrieve_dashboard_url(jhelper_keystone) console.print("The cloud has been configured for sample usage.") diff --git a/sunbeam-python/sunbeam/provider/maas/commands.py b/sunbeam-python/sunbeam/provider/maas/commands.py index 15f028a49..5baf8026e 100644 --- a/sunbeam-python/sunbeam/provider/maas/commands.py +++ b/sunbeam-python/sunbeam/provider/maas/commands.py @@ -134,6 +134,7 @@ DeploySunbeamClusterdApplicationStep, ) from sunbeam.steps.features import DisableEnabledFeatures +from sunbeam.steps.horizon import AttachHorizonThemeStep from sunbeam.steps.hypervisor import ( DeployHypervisorApplicationStep, DestroyHypervisorApplicationStep, @@ -1028,6 +1029,7 @@ def configure_cmd( tfhelper.env = (tfhelper.env or {}) | admin_credentials answer_file = tfhelper.path / "config.auto.tfvars.json" tfhelper_hypervisor = deployment.get_tfhelper("hypervisor-plan") + tfhelper_openstack = deployment.get_tfhelper("openstack-plan") compute = list( map(_name_mapper, client.cluster.list_nodes_by_role(RoleTags.COMPUTE.value)) ) @@ -1079,6 +1081,14 @@ def configure_cmd( manifest, model=deployment.openstack_machines_model, ), + TerraformInitStep(tfhelper_openstack), + AttachHorizonThemeStep( + client=client, + jhelper=jhelper, + tfhelper=tfhelper_openstack, + manifest=manifest, + model=OPENSTACK_MODEL, + ), MaasSetOpenStackNetworkAgentsStep( client, maas_client, diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py new file mode 100644 index 000000000..8ce56f247 --- /dev/null +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -0,0 +1,188 @@ +# SPDX-FileCopyrightText: 2023 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import logging +from pathlib import Path + +from sunbeam.core.common import ( + BaseStep, + Result, + ResultType, + StepContext, +) +from sunbeam.core.juju import JujuHelper +from sunbeam.core.manifest import Manifest +from sunbeam.core.questions import ( + ConfirmQuestion, + PromptQuestion, + QuestionBank, + load_answers, + write_answers, +) +from sunbeam.core.terraform import TerraformHelper +from sunbeam.steps.openstack import CONFIG_KEY as OPENSTACK_TFVAR_CONFIG_KEY + +LOG = logging.getLogger(__name__) +THEME_CONFIG_SECTION = "Horizon" + + +class AttachHorizonThemeStep(BaseStep): + """Prompt for and configure custom theme resources for Horizon.""" + + def __init__( + self, + client, + jhelper: JujuHelper, + tfhelper: TerraformHelper, + manifest: Manifest, + model: str, + ): + 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.variables: dict = {} + + def has_prompts(self) -> bool: + """Indicate that this step requires interactive user input.""" + 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) + + enable_bank = QuestionBank( + questions={ + "enable_custom_theme": ConfirmQuestion( + "Customize available Horizon themes?", + default_value=False, + description=( + "Enables custom themeing as well as controls built-in themes" + ), + ) + }, + console=console, + previous_answers=self.variables, + show_hint=show_hint, + ) + 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" + ), + ), + "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, + ) + 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, + ) + 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.""" + self.variables = load_answers(self.client, THEME_CONFIG_SECTION) + + 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." + ) + + self.jhelper.attach_resource( + application="horizon", + model=self.model, + resource="custom-theme", + filepath=str(theme_path), + ) + + 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, + } + + override_tfvars = {"horizon-config": horizon_config} + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=OPENSTACK_TFVAR_CONFIG_KEY, + override_tfvars=override_tfvars, + reporter=context.reporter, + ) + return Result(ResultType.COMPLETED) From 90a987ddd9fb2324544f8d41d3c37a44ae6a1ea3 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:48 -0400 Subject: [PATCH 02/11] fix: explicitly handle tfvars merge Signed-off-by: guno327 --- sunbeam-python/sunbeam/steps/horizon.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index 8ce56f247..093c5addf 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -4,8 +4,10 @@ import logging from pathlib import Path +from sunbeam.clusterd.service import ConfigItemNotFoundException from sunbeam.core.common import ( BaseStep, + read_config, Result, ResultType, StepContext, @@ -177,7 +179,16 @@ def run(self, context: StepContext) -> Result: "custom-theme-name": None, } - override_tfvars = {"horizon-config": horizon_config} + 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_tfvars = {**current_tfvars, **horizon_config} + override_tfvars = {"horizon-config": merged_tfvars} self.tfhelper.update_tfvars_and_apply_tf( self.client, self.manifest, From d324480c3b75ad5119f6158985d82182e3cd6403 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:49 -0400 Subject: [PATCH 03/11] fix: typo themeing -> theming Signed-off-by: guno327 --- sunbeam-python/sunbeam/steps/horizon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index 093c5addf..e6655c206 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -61,7 +61,7 @@ def prompt(self, console=None, show_hint=False) -> None: "Customize available Horizon themes?", default_value=False, description=( - "Enables custom themeing as well as controls built-in themes" + "Enables custom theming as well as controls built-in themes" ), ) }, From 81ca3fa773171e2890606752223398522b761fb0 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:49 -0400 Subject: [PATCH 04/11] fix: wrap *expected* exceptions in step class rather than propagating Signed-off-by: guno327 --- sunbeam-python/sunbeam/steps/horizon.py | 37 +++++++++++++++---------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index e6655c206..9e059e8e2 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -145,21 +145,24 @@ def prompt(self, console=None, show_hint=False) -> None: def run(self, context: StepContext) -> Result: """Attach the resource and push the configuration via Terraform.""" - self.variables = load_answers(self.client, THEME_CONFIG_SECTION) + self.variables = load_answers(self.client, THEME_CONFIG_SECTION) 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." ) - - self.jhelper.attach_resource( - application="horizon", - model=self.model, - resource="custom-theme", - filepath=str(theme_path), - ) + try: + self.jhelper.attach_resource( + application="horizon", + model=self.model, + resource="custom-theme", + filepath=str(theme_path), + ) + except JujuException as e: + LOG.expection("Failed to attach horizon theme resource") + return Result(ResultType.FAILED, f"failed to attach resource: {str(theme_path)}") horizon_config = { "include-default-themes": not self.variables.get( @@ -189,11 +192,15 @@ def run(self, context: StepContext) -> Result: merged_tfvars = {**current_tfvars, **horizon_config} override_tfvars = {"horizon-config": merged_tfvars} - self.tfhelper.update_tfvars_and_apply_tf( - self.client, - self.manifest, - tfvar_config=OPENSTACK_TFVAR_CONFIG_KEY, - override_tfvars=override_tfvars, - reporter=context.reporter, - ) + 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(override_tfvars)}") return Result(ResultType.COMPLETED) From 9c1020237a1c0902db5113c17e2b334b28bc09d9 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:50 -0400 Subject: [PATCH 05/11] fix: respect accept-defaults as arg to step Signed-off-by: guno327 --- sunbeam-python/sunbeam/steps/horizon.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index 9e059e8e2..37b646242 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -38,6 +38,7 @@ def __init__( tfhelper: TerraformHelper, manifest: Manifest, model: str, + accept_defaults: bool = False, ): super().__init__("Configure Horizon Themes", "Configuring themes for Horizon") self.client = client @@ -45,6 +46,7 @@ def __init__( self.tfhelper = tfhelper self.manifest = manifest self.model = model + self.accept_defaults = accept_defaults self.variables: dict = {} def has_prompts(self) -> bool: @@ -68,6 +70,7 @@ def prompt(self, console=None, show_hint=False) -> None: 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 @@ -110,6 +113,7 @@ def prompt(self, console=None, show_hint=False) -> None: 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() @@ -138,6 +142,7 @@ def prompt(self, console=None, show_hint=False) -> None: 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() From df26f2ba37c729f2507d173c5b91a324b5070709 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:50 -0400 Subject: [PATCH 06/11] feat: add validation function for theme path Signed-off-by: guno327 --- sunbeam-python/sunbeam/steps/horizon.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index 37b646242..403452cc6 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -23,10 +23,23 @@ ) from sunbeam.core.terraform import TerraformHelper from sunbeam.steps.openstack import CONFIG_KEY as OPENSTACK_TFVAR_CONFIG_KEY +from tarfile import is_tarfile 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") + if not path_str.endswith(".tar.gz", ".tgz"): + raise ValueError(f"Theme file must be a .tar.gz archive: {path_str}") + + p = Path(path_str) + if not p.is_file(): + raise ValueError(f"Theme file does not exist: {path_str}") + if not 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.""" @@ -93,6 +106,7 @@ def prompt(self, console=None, show_hint=False) -> None: "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", From 9ebd8b42d779400064e8e7541247b9c12fcfd808 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:51 -0400 Subject: [PATCH 07/11] feat: added attached resource tracking via revision number Signed-off-by: guno327 --- sunbeam-python/sunbeam/core/juju.py | 15 ++++++++++++++- sunbeam-python/sunbeam/steps/horizon.py | 16 +++++++++++++--- sunbeam-python/sunbeam/versions.py | 1 + 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index a11df2dd1..e2686ee1a 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -370,13 +370,14 @@ def attach_resource( application: str, resource: str, filepath: str, - ): + ) -> int: """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): @@ -387,11 +388,23 @@ def attach_resource( json_format=False, include_controller=False, ) + resources = self.cli( + "resources", + application, + include_controller=False, + ) except jubilant.CLIError as e: raise JujuException( f"Failed to attach resource {resource} to {application}: {e.stderr}" ) + for r in resources.get("resources", []): + if r.get("name") == resource: + return r["revision"] + raise JujuException( + f"Resource {resource} not found on {application} after attach" + ) + def integrate( self, model: str, diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index 403452cc6..abe31a21c 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -173,7 +173,7 @@ def run(self, context: StepContext) -> Result: ResultType.FAILED, f"Theme file {theme_path} is invalid or missing." ) try: - self.jhelper.attach_resource( + theme_revision = self.jhelper.attach_resource( application="horizon", model=self.model, resource="custom-theme", @@ -183,6 +183,8 @@ def run(self, context: StepContext) -> Result: LOG.expection("Failed to attach horizon theme resource") return Result(ResultType.FAILED, f"failed to attach resource: {str(theme_path)}") + horizon_resources = {"custom-theme": theme_revision} + horizon_config = { "include-default-themes": not self.variables.get( "disable_default_themes", False @@ -194,6 +196,8 @@ def run(self, context: StepContext) -> Result: "custom-theme-name": self.variables.get("custom_theme_name", "custom"), } else: + horizon_resources = {} + horizon_config = { "include-default-themes": True, "include-ubuntu-theme": True, @@ -209,8 +213,14 @@ def run(self, context: StepContext) -> Result: LOG.exception("Error reading current tfvars") return Result(ResultType.FAILED, f"failed to read tfvars: {str(e)}") - merged_tfvars = {**current_tfvars, **horizon_config} - override_tfvars = {"horizon-config": merged_tfvars} + merged_resources = {**current_tfvars.get("horizon-resources", {}), **horizon_resources} + merged_config = {**current_tfvars.get("horizon-config", {}), **horizon_config} + + override_tfvars = { + "horizon-resources": merged_resources, + "horizon-config": merged_config, + } + try: self.tfhelper.update_tfvars_and_apply_tf( self.client, 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() }, From 335dc20c1c73b71d66cd8fd1baa481208802ac35 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:51 -0400 Subject: [PATCH 08/11] feat: migrated horizon themeing to dedicated 'dashboard' command and added support for manifest Signed-off-by: guno327 --- sunbeam-python/sunbeam/commands/dashboard.py | 130 ++++++++++++++++++ .../sunbeam/commands/dashboard_url.py | 56 -------- sunbeam-python/sunbeam/core/common.py | 8 ++ sunbeam-python/sunbeam/core/juju.py | 2 +- sunbeam-python/sunbeam/core/manifest.py | 12 ++ sunbeam-python/sunbeam/main.py | 4 +- .../sunbeam/provider/local/commands.py | 24 ++-- .../sunbeam/provider/maas/commands.py | 20 +-- sunbeam-python/sunbeam/steps/horizon.py | 67 +++++++-- 9 files changed, 227 insertions(+), 96 deletions(-) create mode 100644 sunbeam-python/sunbeam/commands/dashboard.py delete mode 100644 sunbeam-python/sunbeam/commands/dashboard_url.py diff --git a/sunbeam-python/sunbeam/commands/dashboard.py b/sunbeam-python/sunbeam/commands/dashboard.py new file mode 100644 index 000000000..2cf82981b --- /dev/null +++ b/sunbeam-python/sunbeam/commands/dashboard.py @@ -0,0 +1,130 @@ +# 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 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 e2686ee1a..3d3cf7f05 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -370,7 +370,7 @@ def attach_resource( application: str, resource: str, filepath: str, - ) -> int: + ) -> int: """Attach a file resource to a juju application. :model: Name of the model 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 d224ebc36..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 ( @@ -907,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, @@ -2121,19 +2130,6 @@ def configure_cmd( ) ) - if "control" in node["role"]: - openstack_tfhelper = deployment.get_tfhelper("openstack-plan") - plan.append(TerraformInitStep(openstack_tfhelper)) - plan.append( - AttachHorizonThemeStep( - client=client, - jhelper=jhelper, - tfhelper=openstack_tfhelper, - manifest=manifest, - model=OPENSTACK_MODEL, - ) - ) - run_plan(plan, console, show_hints) dashboard_url = retrieve_dashboard_url(jhelper_keystone) console.print("The cloud has been configured for sample usage.") diff --git a/sunbeam-python/sunbeam/provider/maas/commands.py b/sunbeam-python/sunbeam/provider/maas/commands.py index 5baf8026e..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 ( @@ -855,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( @@ -1029,7 +1038,6 @@ def configure_cmd( tfhelper.env = (tfhelper.env or {}) | admin_credentials answer_file = tfhelper.path / "config.auto.tfvars.json" tfhelper_hypervisor = deployment.get_tfhelper("hypervisor-plan") - tfhelper_openstack = deployment.get_tfhelper("openstack-plan") compute = list( map(_name_mapper, client.cluster.list_nodes_by_role(RoleTags.COMPUTE.value)) ) @@ -1081,14 +1089,6 @@ def configure_cmd( manifest, model=deployment.openstack_machines_model, ), - TerraformInitStep(tfhelper_openstack), - AttachHorizonThemeStep( - client=client, - jhelper=jhelper, - tfhelper=tfhelper_openstack, - manifest=manifest, - model=OPENSTACK_MODEL, - ), MaasSetOpenStackNetworkAgentsStep( client, maas_client, diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index abe31a21c..a57eba0e3 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -1,18 +1,20 @@ -# SPDX-FileCopyrightText: 2023 - Canonical Ltd +# SPDX-FileCopyrightText: 2026 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 import logging from pathlib import Path +from tarfile import is_tarfile from sunbeam.clusterd.service import ConfigItemNotFoundException from sunbeam.core.common import ( BaseStep, - read_config, + PromptMode, Result, ResultType, StepContext, + read_config, ) -from sunbeam.core.juju import JujuHelper +from sunbeam.core.juju import JujuException, JujuHelper from sunbeam.core.manifest import Manifest from sunbeam.core.questions import ( ConfirmQuestion, @@ -21,19 +23,21 @@ load_answers, write_answers, ) -from sunbeam.core.terraform import TerraformHelper +from sunbeam.core.terraform import ( + TerraformException, + TerraformHelper, + TerraformStateLockedException, +) from sunbeam.steps.openstack import CONFIG_KEY as OPENSTACK_TFVAR_CONFIG_KEY -from tarfile import is_tarfile 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""" + """Validate path is a non-empty .tar.gz archive and contains a valid theme.""" if not path_str: raise ValueError("Theme path is required") - if not path_str.endswith(".tar.gz", ".tgz"): - raise ValueError(f"Theme file must be a .tar.gz archive: {path_str}") p = Path(path_str) if not p.is_file(): @@ -41,6 +45,7 @@ def _validate_theme_path(path_str: str): if not 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.""" @@ -52,6 +57,7 @@ def __init__( 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 @@ -60,15 +66,38 @@ def __init__( 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={ @@ -164,8 +193,11 @@ def prompt(self, console=None, show_hint=False) -> None: 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} - self.variables = load_answers(self.client, THEME_CONFIG_SECTION) 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(): @@ -180,8 +212,11 @@ def run(self, context: StepContext) -> Result: filepath=str(theme_path), ) except JujuException as e: - LOG.expection("Failed to attach horizon theme resource") - return Result(ResultType.FAILED, f"failed to attach resource: {str(theme_path)}") + LOG.exception("Failed to attach horizon theme resource") + return Result( + ResultType.FAILED, + f"failed to attach resource {str(theme_path)}: {str(e)}", + ) horizon_resources = {"custom-theme": theme_revision} @@ -213,7 +248,10 @@ def run(self, context: StepContext) -> Result: LOG.exception("Error reading current tfvars") return Result(ResultType.FAILED, f"failed to read tfvars: {str(e)}") - merged_resources = {**current_tfvars.get("horizon-resources", {}), **horizon_resources} + merged_resources = { + **current_tfvars.get("horizon-resources", {}), + **horizon_resources, + } merged_config = {**current_tfvars.get("horizon-config", {}), **horizon_config} override_tfvars = { @@ -231,5 +269,8 @@ def run(self, context: StepContext) -> Result: ) except (TerraformException, TerraformStateLockedException) as e: LOG.exception("Failed to update tfvars") - return Result(ResultType.FAILED, f"failed to update tfvars: {str(override_tfvars)}") + return Result( + ResultType.FAILED, + f"failed to update tfvars: {str(e)}", + ) return Result(ResultType.COMPLETED) From 78d814399613d6abeb6bc84b16e14925effcb736 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:51 -0400 Subject: [PATCH 09/11] feat: add unit testing for horizon/attach_resource/dashboard Signed-off-by: guno327 --- .../unit/sunbeam/commands/test_dashboard.py | 131 +++++++ .../tests/unit/sunbeam/core/test_juju.py | 49 +++ .../tests/unit/sunbeam/steps/test_horizon.py | 335 ++++++++++++++++++ 3 files changed, 515 insertions(+) create mode 100644 sunbeam-python/tests/unit/sunbeam/commands/test_dashboard.py create mode 100644 sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py 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..3a8b6aa18 100644 --- a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py +++ b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py @@ -1277,6 +1277,55 @@ def test_get_relation_map_no_related_units(jhelper, status): jhelper.get_relation_map("app", "certificates", "test-model") +def test_attach_resource_returns_revision(jhelper): + jhelper._juju.cli.side_effect = [ + "", + json.dumps( + { + "resources": [ + {"name": "custom-theme", "revision": 7}, + {"name": "horizon-image", "revision": 1}, + ] + } + ), + ] + rev = jhelper.attach_resource( + model="test-model", + application="horizon", + resource="custom-theme", + filepath="/tmp/theme.tar.gz", + ) + assert rev == 7 + assert jhelper._juju.cli.call_count == 2 + + +def test_attach_resource_not_found_after_attach(jhelper): + jhelper._juju.cli.side_effect = [ + "", + json.dumps({"resources": [{"name": "other-resource", "revision": 1}]}), + ] + with pytest.raises(jujulib.JujuException, match="not found"): + jhelper.attach_resource( + model="test-model", + application="horizon", + resource="custom-theme", + filepath="/tmp/theme.tar.gz", + ) + + +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..6575ab896 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py @@ -0,0 +1,335 @@ +# 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(): + helper = Mock() + helper.attach_resource.return_value = 5 + return helper + + +@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" + assert override["horizon-resources"] == {} + + +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" + assert override["horizon-resources"] == {"custom-theme": 5} + + +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", + }, + "horizon-resources": {"other-resource": 99}, + } + 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" + assert override["horizon-resources"]["other-resource"] == 99 + # Overridden + assert override["horizon-config"]["custom-theme-name"] == "test-theme" + assert override["horizon-resources"]["custom-theme"] == 5 + + +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" From 0c0d89bbd08e49da163ab7603770e7f1a63d1b6a Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 11:25:52 -0400 Subject: [PATCH 10/11] fix: removed revision tracking for theme resource Signed-off-by: guno327 --- sunbeam-python/sunbeam/commands/dashboard.py | 6 +++- sunbeam-python/sunbeam/core/juju.py | 16 ++------- sunbeam-python/sunbeam/steps/horizon.py | 15 ++------ .../tests/unit/sunbeam/core/test_juju.py | 36 ------------------- .../tests/unit/sunbeam/steps/test_horizon.py | 9 +---- 5 files changed, 11 insertions(+), 71 deletions(-) diff --git a/sunbeam-python/sunbeam/commands/dashboard.py b/sunbeam-python/sunbeam/commands/dashboard.py index 2cf82981b..08f06f550 100644 --- a/sunbeam-python/sunbeam/commands/dashboard.py +++ b/sunbeam-python/sunbeam/commands/dashboard.py @@ -7,7 +7,11 @@ from rich.console import Console from sunbeam.core import juju -from sunbeam.core.checks import VerifyBootstrappedCheck, run_preflight_checks +from sunbeam.core.checks import ( + JujuLoginCheck, + VerifyBootstrappedCheck, + run_preflight_checks, +) from sunbeam.core.common import ( PromptMode, run_plan, diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index 3d3cf7f05..6110bc564 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -370,7 +370,7 @@ def attach_resource( application: str, resource: str, filepath: str, - ) -> int: + ): """Attach a file resource to a juju application. :model: Name of the model @@ -388,23 +388,11 @@ def attach_resource( json_format=False, include_controller=False, ) - resources = self.cli( - "resources", - application, - include_controller=False, - ) except jubilant.CLIError as e: raise JujuException( - f"Failed to attach resource {resource} to {application}: {e.stderr}" + f"Failed to attach resource {resource} to {application}: {str(e)}" ) - for r in resources.get("resources", []): - if r.get("name") == resource: - return r["revision"] - raise JujuException( - f"Resource {resource} not found on {application} after attach" - ) - def integrate( self, model: str, diff --git a/sunbeam-python/sunbeam/steps/horizon.py b/sunbeam-python/sunbeam/steps/horizon.py index a57eba0e3..629d758d5 100644 --- a/sunbeam-python/sunbeam/steps/horizon.py +++ b/sunbeam-python/sunbeam/steps/horizon.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 import logging +import tarfile from pathlib import Path -from tarfile import is_tarfile from sunbeam.clusterd.service import ConfigItemNotFoundException from sunbeam.core.common import ( @@ -42,7 +42,7 @@ def _validate_theme_path(path_str: str): p = Path(path_str) if not p.is_file(): raise ValueError(f"Theme file does not exist: {path_str}") - if not is_tarfile(p): + if not tarfile.is_tarfile(p): raise ValueError(f"Theme file is not a valid tarball: {path_str}") @@ -205,7 +205,7 @@ def run(self, context: StepContext) -> Result: ResultType.FAILED, f"Theme file {theme_path} is invalid or missing." ) try: - theme_revision = self.jhelper.attach_resource( + self.jhelper.attach_resource( application="horizon", model=self.model, resource="custom-theme", @@ -218,8 +218,6 @@ def run(self, context: StepContext) -> Result: f"failed to attach resource {str(theme_path)}: {str(e)}", ) - horizon_resources = {"custom-theme": theme_revision} - horizon_config = { "include-default-themes": not self.variables.get( "disable_default_themes", False @@ -231,8 +229,6 @@ def run(self, context: StepContext) -> Result: "custom-theme-name": self.variables.get("custom_theme_name", "custom"), } else: - horizon_resources = {} - horizon_config = { "include-default-themes": True, "include-ubuntu-theme": True, @@ -248,14 +244,9 @@ def run(self, context: StepContext) -> Result: LOG.exception("Error reading current tfvars") return Result(ResultType.FAILED, f"failed to read tfvars: {str(e)}") - merged_resources = { - **current_tfvars.get("horizon-resources", {}), - **horizon_resources, - } merged_config = {**current_tfvars.get("horizon-config", {}), **horizon_config} override_tfvars = { - "horizon-resources": merged_resources, "horizon-config": merged_config, } diff --git a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py index 3a8b6aa18..2ea65d804 100644 --- a/sunbeam-python/tests/unit/sunbeam/core/test_juju.py +++ b/sunbeam-python/tests/unit/sunbeam/core/test_juju.py @@ -1277,42 +1277,6 @@ def test_get_relation_map_no_related_units(jhelper, status): jhelper.get_relation_map("app", "certificates", "test-model") -def test_attach_resource_returns_revision(jhelper): - jhelper._juju.cli.side_effect = [ - "", - json.dumps( - { - "resources": [ - {"name": "custom-theme", "revision": 7}, - {"name": "horizon-image", "revision": 1}, - ] - } - ), - ] - rev = jhelper.attach_resource( - model="test-model", - application="horizon", - resource="custom-theme", - filepath="/tmp/theme.tar.gz", - ) - assert rev == 7 - assert jhelper._juju.cli.call_count == 2 - - -def test_attach_resource_not_found_after_attach(jhelper): - jhelper._juju.cli.side_effect = [ - "", - json.dumps({"resources": [{"name": "other-resource", "revision": 1}]}), - ] - with pytest.raises(jujulib.JujuException, match="not found"): - jhelper.attach_resource( - model="test-model", - application="horizon", - resource="custom-theme", - filepath="/tmp/theme.tar.gz", - ) - - def test_attach_resource_cli_error_wrapped(jhelper): jhelper._juju.cli.side_effect = jubilant.CLIError( 1, "attach-resource", stderr="bad file" diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py b/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py index 6575ab896..a5e4cea6e 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_horizon.py @@ -30,9 +30,7 @@ def client(): @pytest.fixture def jhelper(): - helper = Mock() - helper.attach_resource.return_value = 5 - return helper + return Mock() @pytest.fixture @@ -115,7 +113,6 @@ def test_run_no_custom_theme_applies_defaults( 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" - assert override["horizon-resources"] == {} def test_run_with_manifest_theme_attaches_and_applies( @@ -142,7 +139,6 @@ def test_run_with_manifest_theme_attaches_and_applies( 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" - assert override["horizon-resources"] == {"custom-theme": 5} def test_run_missing_theme_path_fails( @@ -258,7 +254,6 @@ def test_run_merges_with_existing_tfvars( "debug": "true", "custom-theme-name": "old-theme", }, - "horizon-resources": {"other-resource": 99}, } step = _make_step(client, jhelper, tfhelper, manifest) result = step.run(step_context) @@ -267,10 +262,8 @@ def test_run_merges_with_existing_tfvars( override = tfhelper.update_tfvars_and_apply_tf.call_args.kwargs["override_tfvars"] # Preserved assert override["horizon-config"]["debug"] == "true" - assert override["horizon-resources"]["other-resource"] == 99 # Overridden assert override["horizon-config"]["custom-theme-name"] == "test-theme" - assert override["horizon-resources"]["custom-theme"] == 5 def test_has_prompts_force_mode(client, jhelper, tfhelper, manifest_empty): From db035d899e0a97b90b10eb7d821be2009079f8f4 Mon Sep 17 00:00:00 2001 From: guno327 Date: Thu, 28 May 2026 13:46:32 -0400 Subject: [PATCH 11/11] fix: expose new dashboard commands in snap completions --- snap-wrappers/completions/generate_completion_cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)