From 4c9f4d9e21e3be5e280e571d29aaea13e51ad694 Mon Sep 17 00:00:00 2001 From: Hemanth Nakkina Date: Mon, 16 Mar 2026 15:56:14 +0530 Subject: [PATCH] tls: add sunbeam cluster refresh manual-tls-certificates-operator command - Add MANUAL_TLS_CERTIFICATES_CHANNEL constant to versions.py and replace hardcoded "1/stable"/"latest/stable" strings in tls/ca.py, tls/common.py and tls/vault.py - Add manual-tls-certificates to INFRA_APPS so the generic LatestInChannel refresh skips it - Add ManualTLSCharmUpgradeStep (steps/manual_tls.py) using the shared check_charm_needs_refresh helper (charm_upgrade.py) which: - Skips if the app is not deployed - Skips if already at the manifest-pinned revision - Skips if already at the latest revision on the effective channel - Returns FAILED for invalid/downgrade channel in manifest - Passes --channel to charm_refresh only when the channel actually changes (avoids unintended track switches on patch upgrades) - Waits for active via wait_until_active (catches JujuWaitException in addition to TimeoutError) - Updates terraform tfvars after refresh - Add `sunbeam cluster refresh manual-tls-certificates-operator` subcommand with optional -m/--manifest flag - Add unit tests covering all skip/run branches --- sunbeam-python/sunbeam/commands/refresh.py | 51 ++++ sunbeam-python/sunbeam/features/tls/ca.py | 3 +- sunbeam-python/sunbeam/features/tls/common.py | 3 +- sunbeam-python/sunbeam/features/tls/vault.py | 3 +- sunbeam-python/sunbeam/steps/manual_tls.py | 139 ++++++++++ .../sunbeam/steps/upgrades/intra_channel.py | 2 +- .../sunbeam/steps/test_manual_tls_upgrade.py | 245 ++++++++++++++++++ 7 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 sunbeam-python/sunbeam/steps/manual_tls.py create mode 100644 sunbeam-python/tests/unit/sunbeam/steps/test_manual_tls_upgrade.py diff --git a/sunbeam-python/sunbeam/commands/refresh.py b/sunbeam-python/sunbeam/commands/refresh.py index d9fb097cc..d09363075 100644 --- a/sunbeam-python/sunbeam/commands/refresh.py +++ b/sunbeam-python/sunbeam/commands/refresh.py @@ -25,6 +25,7 @@ from sunbeam.features.interface.v1.base import is_maas_deployment from sunbeam.steps.k8s import DeployK8SApplicationStep from sunbeam.steps.k8s_upgrade import K8SCharmUpgradeStep +from sunbeam.steps.manual_tls import ManualTLSCharmUpgradeStep from sunbeam.steps.upgrades.base import UpgradeCoordinator from sunbeam.steps.upgrades.inter_channel import ChannelUpgradeCoordinator from sunbeam.steps.upgrades.intra_channel import ( @@ -385,3 +386,53 @@ def refresh_k8s( if message: click.echo(message) click.echo("k8s refresh complete.") + + +@refresh.command("manual-tls-certificates-operator") +@click.option( + "-m", + "--manifest", + "manifest_path", + help="Manifest file.", + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +@click_option_show_hints +@click.pass_context +def refresh_manual_tls_certificates( + ctx: click.Context, + manifest_path: Path | None = None, + show_hints: bool = False, +) -> None: + """Upgrade manual-tls-certificates charm to latest channel/revision.""" + deployment: Deployment = ctx.obj + client = deployment.get_client() + jhelper = JujuHelper(deployment.juju_controller) + tfhelper = deployment.get_tfhelper("openstack-plan") + + manifest = None + if manifest_path: + manifest = deployment.get_manifest(manifest_path) + run_plan([AddManifestStep(client, manifest_path)], console, show_hints) + + if not manifest: + LOG.debug("Getting latest manifest from cluster db") + manifest = deployment.get_manifest() + + plan_results = run_plan( + [ + ManualTLSCharmUpgradeStep( + deployment, + client, + manifest, + jhelper, + tfhelper, + ) + ], + console, + show_hints, + ) + + message = get_step_message(plan_results, ManualTLSCharmUpgradeStep) + if message: + click.echo(message) + click.echo("manual-tls-certificates refresh complete.") diff --git a/sunbeam-python/sunbeam/features/tls/ca.py b/sunbeam-python/sunbeam/features/tls/ca.py index 5ec2fd502..15a01edc2 100644 --- a/sunbeam-python/sunbeam/features/tls/ca.py +++ b/sunbeam-python/sunbeam/features/tls/ca.py @@ -51,6 +51,7 @@ handle_list_outstanding_csrs, ) from sunbeam.utils import click_option_show_hints, pass_method_obj +from sunbeam.versions import MANUAL_TLS_CERTIFICATES_CHANNEL LOG = logging.getLogger(__name__) console = Console() @@ -149,7 +150,7 @@ def set_tfvars_on_enable( """Set terraform variables to enable the application.""" tfvars: dict[str, str | bool] = { "traefik-to-tls-provider": CA_MANUAL_TLS_CERTIFICATE, - "manual-tls-certificates-channel": "1/stable", + "manual-tls-certificates-channel": MANUAL_TLS_CERTIFICATES_CHANNEL, } if "public" in config.endpoints: tfvars.update({"enable-tls-for-public-endpoint": True}) diff --git a/sunbeam-python/sunbeam/features/tls/common.py b/sunbeam-python/sunbeam/features/tls/common.py index 8746bf01d..d740f2e53 100644 --- a/sunbeam-python/sunbeam/features/tls/common.py +++ b/sunbeam-python/sunbeam/features/tls/common.py @@ -49,6 +49,7 @@ WaitForApplicationsStep, ) from sunbeam.utils import pass_method_obj +from sunbeam.versions import MANUAL_TLS_CERTIFICATES_CHANNEL CERTIFICATE_FEATURE_KEY = "TlsProvider" CA_MANUAL_TLS_CERTIFICATE = "manual-tls-certificates" @@ -108,7 +109,7 @@ def default_software_overrides(self) -> SoftwareConfig: return SoftwareConfig( charms={ "manual-tls-certificates": CharmManifest( - channel="latest/stable", + channel=MANUAL_TLS_CERTIFICATES_CHANNEL, ) } ) diff --git a/sunbeam-python/sunbeam/features/tls/vault.py b/sunbeam-python/sunbeam/features/tls/vault.py index 3410ec4ad..334252883 100644 --- a/sunbeam-python/sunbeam/features/tls/vault.py +++ b/sunbeam-python/sunbeam/features/tls/vault.py @@ -56,6 +56,7 @@ vault_pki_config_key, ) from sunbeam.utils import click_option_show_hints, pass_method_obj +from sunbeam.versions import MANUAL_TLS_CERTIFICATES_CHANNEL CA_APP_NAME = "vault" LOG = logging.getLogger(__name__) @@ -232,7 +233,7 @@ def set_tfvars_on_enable( """Set terraform variables to enable the application.""" tfvars: dict[str, typing.Any] = { "traefik-to-tls-provider": CA_APP_NAME, - "manual-tls-certificates-channel": "1/stable", + "manual-tls-certificates-channel": MANUAL_TLS_CERTIFICATES_CHANNEL, } jhelper = JujuHelper(deployment.juju_controller) vault_channel = self._get_vault_channel(jhelper) diff --git a/sunbeam-python/sunbeam/steps/manual_tls.py b/sunbeam-python/sunbeam/steps/manual_tls.py new file mode 100644 index 000000000..3b6557fff --- /dev/null +++ b/sunbeam-python/sunbeam/steps/manual_tls.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +import logging + +from sunbeam.clusterd.client import Client +from sunbeam.core.common import ( + BaseStep, + Result, + ResultType, + StepContext, +) +from sunbeam.core.deployment import Deployment +from sunbeam.core.juju import ( + JujuException, + JujuHelper, + JujuStepHelper, + JujuWaitException, +) +from sunbeam.core.manifest import Manifest +from sunbeam.core.openstack import OPENSTACK_MODEL +from sunbeam.core.terraform import TerraformException, TerraformHelper +from sunbeam.features.interface.v1.openstack import OPENSTACK_TERRAFORM_VARS +from sunbeam.steps.charm_upgrade import CharmRefreshDecision, check_charm_needs_refresh +from sunbeam.versions import MANUAL_TLS_CERTIFICATES_CHANNEL + +LOG = logging.getLogger(__name__) + +CHARM_NAME = "manual-tls-certificates" +MANUAL_TLS_UPGRADE_TIMEOUT = 300 + + +class ManualTLSCharmUpgradeStep(BaseStep, JujuStepHelper): + """Upgrade the manual-tls-certificates charm to latest channel/revision.""" + + def __init__( + self, + deployment: Deployment, + client: Client, + manifest: Manifest, + jhelper: JujuHelper, + tfhelper: TerraformHelper, + application: str = CHARM_NAME, + ): + super().__init__( + "Upgrade manual-tls-certificates", + "Upgrading manual-tls-certificates to latest channel/revision", + ) + self.deployment = deployment + self.client = client + self.manifest = manifest + self.jhelper = jhelper + self.tfhelper = tfhelper + self.application = application + self.tfvar_config = OPENSTACK_TERRAFORM_VARS + self._decision: CharmRefreshDecision + + def is_skip(self, context: StepContext) -> Result: + """Skip if manual-tls-certificates is not deployed or already up-to-date.""" + decision = check_charm_needs_refresh( + self.jhelper, + self.manifest, + CHARM_NAME, + OPENSTACK_MODEL, + self.application, + default_channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + support_track_upgrades=True, + ) + self._decision = decision + if decision.result.result_type == ResultType.FAILED: + return Result( + ResultType.FAILED, + f"manual-tls-certificates upgrade failed: {decision.result.message}", + ) + return decision.result + + def run(self, context: StepContext) -> Result: + """Refresh the manual-tls-certificates charm.""" + target_channel = self._decision.effective_channel + revision = self._decision.effective_revision + refresh_channel = target_channel if self._decision.needs_channel_flag else None + + try: + self.update_status( + context, + f"Refreshing {CHARM_NAME} to channel {target_channel}" + + (f" revision {revision}" if revision else ""), + ) + self.jhelper.charm_refresh( + self.application, + OPENSTACK_MODEL, + channel=refresh_channel, + revision=revision, + ) + except JujuException as e: + LOG.error(f"Failed to refresh {CHARM_NAME}: {e}") + return Result( + ResultType.FAILED, + f"Failed to refresh {CHARM_NAME}: {e}", + ) + + try: + self.update_status(context, f"Waiting for {CHARM_NAME} to stabilise") + self.jhelper.wait_until_active( + OPENSTACK_MODEL, + apps=[self.application], + timeout=MANUAL_TLS_UPGRADE_TIMEOUT, + ) + except (JujuWaitException, TimeoutError) as e: + LOG.error(f"Timed out waiting for {self.application}: {e}") + return Result( + ResultType.FAILED, + f"Timed out waiting for {self.application} to stabilise: {e}", + ) + + # Update terraform state with the channel used. Manifest takes + # precedence over override_tfvars for charm keys. + try: + self.update_status( + context, + f"Updating terraform plan with new {CHARM_NAME} channel", + ) + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=self.tfvar_config, + override_tfvars={ + "manual-tls-certificates-channel": target_channel, + }, + ) + except TerraformException as e: + LOG.warning( + f"Failed to reapply terraform plan after {CHARM_NAME} upgrade: {e}" + ) + + return Result( + ResultType.COMPLETED, + f"{CHARM_NAME} upgraded successfully.", + ) diff --git a/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py b/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py index c35952c6f..decbbc3b2 100644 --- a/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py +++ b/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py @@ -49,7 +49,7 @@ LOG = logging.getLogger(__name__) console = Console() -INFRA_APPS = ["mysql-k8s", "vault-k8s", "k8s"] +INFRA_APPS = ["mysql-k8s", "vault-k8s", "k8s", "manual-tls-certificates"] # Snap-based charm applications that expose a refresh-snap action. # These need to be refreshed explicitly after the charm refresh because diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_manual_tls_upgrade.py b/sunbeam-python/tests/unit/sunbeam/steps/test_manual_tls_upgrade.py new file mode 100644 index 000000000..e0fab3b3b --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_manual_tls_upgrade.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: 2024 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import Mock + +import pytest + +from sunbeam.core.common import Result, ResultType +from sunbeam.core.juju import ( + ApplicationNotFoundException, + JujuException, + JujuWaitException, +) +from sunbeam.steps.charm_upgrade import CharmRefreshDecision +from sunbeam.steps.manual_tls import ( + CHARM_NAME, + MANUAL_TLS_UPGRADE_TIMEOUT, + ManualTLSCharmUpgradeStep, +) +from sunbeam.versions import MANUAL_TLS_CERTIFICATES_CHANNEL + + +@pytest.fixture +def step(basic_deployment, basic_client, basic_manifest, basic_jhelper, basic_tfhelper): + return ManualTLSCharmUpgradeStep( + basic_deployment, + basic_client, + basic_manifest, + basic_jhelper, + basic_tfhelper, + ) + + +def _app( + channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + rev=10, + base_name="ubuntu", + base_channel="22.04", +): + base = Mock(name=base_name, channel=base_channel) + return Mock(charm_channel=channel, charm_rev=rev, base=base) + + +class TestManualTLSCharmUpgradeStepIsSkip: + def test_skip_when_application_not_deployed( + self, step, basic_jhelper, step_context + ): + basic_jhelper.get_application.side_effect = ApplicationNotFoundException() + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.SKIPPED + assert "not been deployed" in result.message + + def test_skip_when_revision_pinned_and_already_deployed( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app(rev=5) + basic_manifest.find_charm.return_value = Mock( + revision=5, channel=MANUAL_TLS_CERTIFICATES_CHANNEL + ) + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.SKIPPED + assert "manifest pinned revision" in result.message + basic_jhelper.get_available_charm_revisions.assert_not_called() + + def test_not_skipped_when_revision_pinned_but_differs_from_deployed( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app(rev=3) + basic_manifest.find_charm.return_value = Mock( + revision=5, channel=MANUAL_TLS_CERTIFICATES_CHANNEL + ) + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + basic_jhelper.get_available_charm_revisions.assert_not_called() + + def test_skip_when_already_at_latest_revision_on_same_channel( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app( + channel=MANUAL_TLS_CERTIFICATES_CHANNEL, rev=42 + ) + basic_jhelper.get_available_charm_revisions.return_value = {"amd64": 42} + basic_manifest.find_charm.return_value = None + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.SKIPPED + + def test_not_skipped_when_newer_revision_available_same_channel( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app( + channel=MANUAL_TLS_CERTIFICATES_CHANNEL, rev=10 + ) + basic_jhelper.get_available_charm_revisions.return_value = {"amd64": 11} + basic_manifest.find_charm.return_value = None + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + + def test_not_skipped_when_channel_changed_same_track( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app(channel="1/stable", rev=10) + basic_manifest.find_charm.return_value = Mock(revision=None, channel="1/edge") + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + basic_jhelper.get_available_charm_revisions.assert_not_called() + + def test_not_skipped_when_cross_track_channel_in_manifest( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app(channel="1/stable", rev=10) + basic_manifest.find_charm.return_value = Mock(revision=None, channel="2/stable") + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + + def test_proceeds_when_revision_lookup_fails( + self, step, basic_jhelper, basic_manifest, step_context + ): + basic_jhelper.get_application.return_value = _app() + basic_jhelper.get_available_charm_revisions.side_effect = JujuException( + "lookup failed" + ) + basic_manifest.find_charm.return_value = None + + result = step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + + +class TestManualTLSCharmUpgradeStepRun: + def test_run_success(self, step, basic_jhelper, step_context): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + needs_channel_flag=False, + ) + + result = step.run(step_context) + + assert result.result_type == ResultType.COMPLETED + basic_jhelper.charm_refresh.assert_called_once_with( + CHARM_NAME, + "openstack", + channel=None, + revision=None, + ) + basic_jhelper.wait_until_active.assert_called_once_with( + "openstack", + apps=[CHARM_NAME], + timeout=MANUAL_TLS_UPGRADE_TIMEOUT, + ) + + def test_run_passes_channel_when_needs_channel_flag( + self, step, basic_jhelper, step_context + ): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel="1/edge", + needs_channel_flag=True, + ) + + step.run(step_context) + + basic_jhelper.charm_refresh.assert_called_once_with( + CHARM_NAME, + "openstack", + channel="1/edge", + revision=None, + ) + + def test_run_uses_channel_and_revision_from_decision( + self, step, basic_jhelper, step_context + ): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel="2/stable", + needs_channel_flag=True, + effective_revision=42, + ) + + step.run(step_context) + + basic_jhelper.charm_refresh.assert_called_once_with( + CHARM_NAME, + "openstack", + channel="2/stable", + revision=42, + ) + + def test_run_charm_refresh_fails(self, step, basic_jhelper, step_context): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + needs_channel_flag=False, + ) + basic_jhelper.charm_refresh.side_effect = JujuException("refresh failed") + + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + assert "refresh failed" in result.message + basic_jhelper.wait_until_active.assert_not_called() + + def test_run_wait_times_out(self, step, basic_jhelper, step_context): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + needs_channel_flag=False, + ) + basic_jhelper.wait_until_active.side_effect = JujuWaitException("timed out") + + result = step.run(step_context) + + assert result.result_type == ResultType.FAILED + assert "timed out" in result.message + + def test_run_updates_tfvars_with_channel( + self, step, basic_jhelper, basic_tfhelper, step_context + ): + step._decision = CharmRefreshDecision( + result=Result(ResultType.COMPLETED), + effective_channel=MANUAL_TLS_CERTIFICATES_CHANNEL, + needs_channel_flag=False, + ) + + step.run(step_context) + + basic_tfhelper.update_tfvars_and_apply_tf.assert_called_once() + _, kwargs = basic_tfhelper.update_tfvars_and_apply_tf.call_args + assert kwargs["override_tfvars"] == { + "manual-tls-certificates-channel": MANUAL_TLS_CERTIFICATES_CHANNEL + }