diff --git a/README.md b/README.md index 7a7cf4e..ce19977 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,17 @@ ubuntu@infra1:~$ juju run maas-region/0 create-admin username=admin password=pas # Managing the cluster after initial deployment + +## Cluster updates + +You can refresh the cluster by running the `refresh` command: + +```bash +ubuntu@infra1:~$ maas-anvil refresh +``` + +This allows passing a new manifest file with `--manifest` for updating configuration options. + ## Juju permission denied If you get an error message such as: diff --git a/anvil-python/anvil/commands/haproxy.py b/anvil-python/anvil/commands/haproxy.py index 678469d..2617ffe 100644 --- a/anvil-python/anvil/commands/haproxy.py +++ b/anvil-python/anvil/commands/haproxy.py @@ -97,6 +97,15 @@ def get_application_timeout(self) -> int: return HAPROXY_APP_TIMEOUT def has_prompts(self) -> bool: + """Returns true if the step has prompts that it can ask the user. + + :return: True if the step can ask the user for prompts, + False otherwise + """ + # No need to prompt for questions in case of refresh + if self.refresh: + return False + return True def prompt(self, console: Console | None = None) -> None: @@ -198,3 +207,23 @@ def haproxy_install_steps( ), AddHAProxyUnitsStep(client, fqdn, jhelper, model), ] + + +def haproxy_upgrade_steps( + client: Client, + manifest: Manifest, + jhelper: JujuHelper, + model: str, + preseed: dict[Any, Any], +) -> List[BaseStep]: + return [ + TerraformInitStep(manifest.get_tfhelper("haproxy-plan")), + DeployHAProxyApplicationStep( + client, + manifest, + jhelper, + model, + deployment_preseed=preseed, + refresh=True, + ), + ] diff --git a/anvil-python/anvil/commands/maas_agent.py b/anvil-python/anvil/commands/maas_agent.py index 7fbc7c8..851fc2b 100644 --- a/anvil-python/anvil/commands/maas_agent.py +++ b/anvil-python/anvil/commands/maas_agent.py @@ -123,3 +123,17 @@ def maas_agent_install_steps( DeployMAASAgentApplicationStep(client, manifest, jhelper, model), AddMAASAgentUnitsStep(client, fqdn, jhelper, model), ] + + +def maas_agent_upgrade_steps( + client: Client, + manifest: Manifest, + jhelper: JujuHelper, + model: str, +) -> List[BaseStep]: + return [ + TerraformInitStep(manifest.get_tfhelper("maas-agent-plan")), + DeployMAASAgentApplicationStep( + client, manifest, jhelper, model, refresh=True + ), + ] diff --git a/anvil-python/anvil/commands/maas_region.py b/anvil-python/anvil/commands/maas_region.py index 046895c..4f60ef2 100644 --- a/anvil-python/anvil/commands/maas_region.py +++ b/anvil-python/anvil/commands/maas_region.py @@ -131,3 +131,17 @@ def maas_region_install_steps( DeployMAASRegionApplicationStep(client, manifest, jhelper, model), AddMAASRegionUnitsStep(client, fqdn, jhelper, model), ] + + +def maas_region_upgrade_steps( + client: Client, + manifest: Manifest, + jhelper: JujuHelper, + model: str, +) -> List[BaseStep]: + return [ + TerraformInitStep(manifest.get_tfhelper("maas-region-plan")), + DeployMAASRegionApplicationStep( + client, manifest, jhelper, model, refresh=True + ), + ] diff --git a/anvil-python/anvil/commands/postgresql.py b/anvil-python/anvil/commands/postgresql.py index 9bd4a55..89eb1b4 100644 --- a/anvil-python/anvil/commands/postgresql.py +++ b/anvil-python/anvil/commands/postgresql.py @@ -65,6 +65,26 @@ def postgresql_install_steps( ] +def postgresql_upgrade_steps( + client: Client, + manifest: Manifest, + jhelper: JujuHelper, + model: str, + preseed: dict[Any, Any], +) -> List[BaseStep]: + return [ + TerraformInitStep(manifest.get_tfhelper("postgresql-plan")), + DeployPostgreSQLApplicationStep( + client, + manifest, + jhelper, + model, + deployment_preseed=preseed, + refresh=True, + ), + ] + + def postgresql_questions() -> dict[str, questions.PromptQuestion]: return { "max_connections": questions.PromptQuestion( @@ -153,6 +173,15 @@ def extra_tfvars(self) -> dict[str, Any]: return variables def has_prompts(self) -> bool: + """Returns true if the step has prompts that it can ask the user. + + :return: True if the step can ask the user for prompts, + False otherwise + """ + # No need to prompt for questions in case of refresh + if self.refresh: + return False + return True diff --git a/anvil-python/anvil/commands/refresh.py b/anvil-python/anvil/commands/refresh.py new file mode 100644 index 0000000..a4a311e --- /dev/null +++ b/anvil-python/anvil/commands/refresh.py @@ -0,0 +1,85 @@ +# Copyright (c) 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from pathlib import Path + +import click +from rich.console import Console +from sunbeam.jobs.common import ( + run_plan, +) +from sunbeam.jobs.juju import JujuHelper +from sunbeam.jobs.manifest import AddManifestStep + +from anvil.commands.upgrades.intra_channel import LatestInChannelCoordinator +from anvil.jobs.manifest import Manifest +from anvil.provider.local.deployment import LocalDeployment + +LOG = logging.getLogger(__name__) +console = Console() + + +@click.command() +@click.option( + "-m", + "--manifest", + "manifest_path", + help="Manifest file.", + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +@click.pass_context +def refresh( + ctx: click.Context, + manifest_path: Path | None = None, +) -> None: + """Refresh deployment. + + Refresh the deployment and allow passing new configuration options. + """ + + deployment: LocalDeployment = ctx.obj + client = deployment.get_client() + + manifest = None + if manifest_path: + manifest = Manifest.load( + deployment, manifest_file=manifest_path, include_defaults=True + ) + run_plan([AddManifestStep(client, manifest)], console) + + if not manifest: + LOG.debug("Getting latest manifest from cluster db") + manifest = Manifest.load_latest_from_clusterdb( + deployment, include_defaults=True + ) + + LOG.debug( + f"Manifest used for refresh - deployment preseed: {manifest.deployment_config}" + ) + LOG.debug( + f"Manifest used for refresh - software: {manifest.software_config}" + ) + jhelper = JujuHelper(deployment.get_connected_controller()) + + a = LatestInChannelCoordinator( + deployment, + client, + jhelper, + manifest, + ) + a.run_plan() + + click.echo("Refresh complete.") diff --git a/anvil-python/anvil/commands/upgrades/__init__.py b/anvil-python/anvil/commands/upgrades/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anvil-python/anvil/commands/upgrades/base.py b/anvil-python/anvil/commands/upgrades/base.py new file mode 100644 index 0000000..4e4747a --- /dev/null +++ b/anvil-python/anvil/commands/upgrades/base.py @@ -0,0 +1,54 @@ +# Copyright (c) 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from rich.console import Console +from rich.status import Status +from sunbeam.jobs.common import ( + BaseStep, + Result, + ResultType, +) +from sunbeam.jobs.deployment import Deployment + +from anvil.jobs.plugin import PluginManager + +LOG = logging.getLogger(__name__) +console = Console() + + +class UpgradePlugins(BaseStep): + def __init__( + self, + deployment: Deployment, + upgrade_release: bool = False, + ): + """Upgrade plugins. + + :client: Helper for interacting with clusterd + :upgrade_release: Whether to upgrade channel + """ + super().__init__("Validation", "Running pre-upgrade validation") + self.deployment = deployment + self.upgrade_release = upgrade_release + + def run(self, status: Status | None = None) -> Result: + PluginManager.update_plugins( + self.deployment, + repos=["core"], + upgrade_release=self.upgrade_release, + ) + return Result(ResultType.COMPLETED) diff --git a/anvil-python/anvil/commands/upgrades/intra_channel.py b/anvil-python/anvil/commands/upgrades/intra_channel.py new file mode 100644 index 0000000..6242391 --- /dev/null +++ b/anvil-python/anvil/commands/upgrades/intra_channel.py @@ -0,0 +1,198 @@ +# Copyright (c) 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from rich.console import Console +from rich.status import Status +from sunbeam.clusterd.client import Client +from sunbeam.commands.juju import JujuStepHelper +from sunbeam.jobs.common import ( + BaseStep, + Result, + ResultType, + run_plan, +) +from sunbeam.jobs.deployment import Deployment +from sunbeam.jobs.juju import JujuHelper, run_sync + +from anvil.commands.haproxy import haproxy_upgrade_steps +from anvil.commands.maas_agent import maas_agent_upgrade_steps +from anvil.commands.maas_region import maas_region_upgrade_steps +from anvil.commands.postgresql import postgresql_upgrade_steps +from anvil.commands.upgrades.base import ( + UpgradePlugins, +) +from anvil.jobs.manifest import Manifest + +LOG = logging.getLogger(__name__) +console = Console() + + +class LatestInChannel(BaseStep, JujuStepHelper): + def __init__(self, jhelper: JujuHelper, manifest: Manifest): + """Upgrade all charms to latest in current channel. + + :jhelper: Helper for interacting with pylibjuju + """ + super().__init__( + "In channel upgrade", + "Upgrade charms to latest revision in current channel", + ) + self.jhelper = jhelper + self.manifest = manifest + + def is_skip(self, status: Status | None = None) -> Result: + """Step can be skipped if nothing needs refreshing.""" + return Result(ResultType.COMPLETED) + + def is_track_changed_for_any_charm( + self, deployed_apps: dict[str, tuple[str, str, str]] + ) -> bool: + """Check if chanel track is same in manifest and deployed app.""" + for name, (charm, channel, revision) in deployed_apps.items(): + charm_manifest = (self.manifest.software_config.charms or {}).get( + charm + ) + if not charm_manifest: + LOG.debug(f"Charm not present in manifest: {charm}") + continue + + if (charm_manifest.channel or "").split("/")[0] != channel.split( + "/" + )[0]: + LOG.debug( + f"Channel for {name} in manifest does not match deployed" + ) + return True + + return False + + def refresh_apps( + self, apps: dict[str, tuple[str, str, str]], model: str + ) -> None: + """Refresh apps in the model. + + If the charm has no revision in manifest and channel mentioned in manifest + and the deployed app is same, run juju refresh. + Otherwise ignore so that terraform plan apply will take care of charm upgrade. + """ + for name, (charm, channel, revision) in apps.items(): + charm_manifest = (self.manifest.software_config.charms or {}).get( + charm + ) + if not charm_manifest: + continue + + if ( + not charm_manifest.revision + and charm_manifest.channel == channel + ): + app = run_sync(self.jhelper.get_application(name, model)) + LOG.debug(f"Running refresh for app {name}") + run_sync(app.refresh()) + + def run(self, status: Status | None = None) -> Result: + """Refresh all charms identified as needing a refresh. + + If the manifest has charm channel and revision, terraform apply should update + the charms. + If the manifest has only charm, then juju refresh is required if channel is + same as deployed charm, otherwise juju upgrade charm. + """ + deployed_machine_apps = self.get_charm_deployed_versions("controller") + + all_deployed_apps = deployed_machine_apps.copy() + LOG.debug(f"All deployed apps: {all_deployed_apps}") + if self.is_track_changed_for_any_charm(all_deployed_apps): + error_msg = "MAAS Anvil cannot upgrade across tracks! Please modify refresh manifest." + return Result(ResultType.FAILED, error_msg) + + self.refresh_apps(deployed_machine_apps, "controller") + return Result(ResultType.COMPLETED) + + +class LatestInChannelCoordinator: + """Coordinator for refreshing charms in their current channel.""" + + def __init__( + self, + deployment: Deployment, + client: Client, + jhelper: JujuHelper, + manifest: Manifest, + ): + """Upgrade coordinator. + + Execute plan for conducting an upgrade. + + :client: Helper for interacting with clusterd + :jhelper: Helper for interacting with pylibjuju + :manifest: Manifest object + """ + self.deployment = deployment + self.client = client + self.jhelper = jhelper + self.manifest = manifest + self.preseed = self.manifest.deployment_config + + def run_plan(self) -> None: + """Execute the upgrade plan.""" + plan = self.get_plan() + run_plan(plan, console) + + def get_plan(self) -> list[BaseStep]: + """Return the upgrade plan.""" + plan: list[BaseStep] = [] + plan.append(LatestInChannel(self.jhelper, self.manifest)) + + plan.extend( + haproxy_upgrade_steps( + self.client, + self.manifest, + self.jhelper, + self.deployment.infrastructure_model, + self.preseed, + ) + ) + plan.extend( + postgresql_upgrade_steps( + self.client, + self.manifest, + self.jhelper, + self.deployment.infrastructure_model, + self.preseed, + ) + ) + plan.extend( + maas_region_upgrade_steps( + self.client, + self.manifest, + self.jhelper, + self.deployment.infrastructure_model, + ) + ) + plan.extend( + maas_agent_upgrade_steps( + self.client, + self.manifest, + self.jhelper, + self.deployment.infrastructure_model, + ) + ) + + plan.append(UpgradePlugins(self.deployment, upgrade_release=False)) + + return plan diff --git a/anvil-python/anvil/main.py b/anvil-python/anvil/main.py index 04e65ae..13d5a8f 100644 --- a/anvil-python/anvil/main.py +++ b/anvil-python/anvil/main.py @@ -25,6 +25,7 @@ inspect as inspect_cmds, manifest as manifest_commands, prepare_node as prepare_node_cmds, + refresh as refresh_cmds, ) from anvil.provider.local.commands import LocalProvider from anvil.provider.local.deployment import LocalDeployment @@ -60,6 +61,7 @@ def main() -> None: log.setup_root_logging(logfile) cli.add_command(prepare_node_cmds.prepare_node_script) cli.add_command(inspect_cmds.inspect) + cli.add_command(refresh_cmds.refresh) # Cluster management deployment = LocalDeployment() diff --git a/cloud/etc/deploy-haproxy/main.tf b/cloud/etc/deploy-haproxy/main.tf index ad6b2be..24ec9fd 100644 --- a/cloud/etc/deploy-haproxy/main.tf +++ b/cloud/etc/deploy-haproxy/main.tf @@ -53,6 +53,7 @@ resource "juju_application" "keepalived" { count = min(length(var.virtual_ip), 1) name = "keepalived" model = data.juju_model.machine_model.name + units = 0 # subordinate charm charm { name = "keepalived"