diff --git a/.github/workflows/build-snap.yml b/.github/workflows/build-snap.yml index 61028715..ad661d4f 100644 --- a/.github/workflows/build-snap.yml +++ b/.github/workflows/build-snap.yml @@ -48,6 +48,15 @@ jobs: sudo apt remove --purge docker.io containerd runc -y sudo rm -rf /run/containerd + sudo lxd init --auto + sudo lxc network create br0 + sudo lxc profile device remove default eth0 + sudo lxc profile device add default eth0 nic network=br0 name=eth0 + sudo lxc network delete lxdbr0 + + sudo snap install juju --channel 3.6/stable + juju bootstrap localhost lxdcloud + sudo snap install ${{ needs.build.outputs.snap }} --dangerous openstack.sunbeam prepare-node-script | bash -x sudo snap connect openstack:juju-bin juju:juju-bin diff --git a/.github/workflows/test-snap.yml b/.github/workflows/test-snap.yml index afeea163..5bdee3b8 100644 --- a/.github/workflows/test-snap.yml +++ b/.github/workflows/test-snap.yml @@ -74,6 +74,15 @@ jobs: # 2023.x, 2024.1/beta does not support k8s provider if [[ ! ${{ inputs.snap-channel }} =~ "2024.1/edge" && ${{ inputs.k8s-provider }} == "k8s" ]]; then echo "k8s provider not supported"; exit 1; fi + sudo lxd init --auto + sudo lxc network create br0 + sudo lxc profile device remove default eth0 + sudo lxc profile device add default eth0 nic network=br0 name=eth0 + sudo lxc network delete lxdbr0 + + sudo snap install juju --channel 3.6/stable + juju bootstrap localhost lxdcloud + sudo snap install openstack --channel ${{ inputs.snap-channel }} sudo snap set openstack k8s.provider=${{ inputs.k8s-provider }} diff --git a/sunbeam-python/sunbeam/clusterd/cluster.py b/sunbeam-python/sunbeam/clusterd/cluster.py index 3eb34b5f..3d2ec8a5 100644 --- a/sunbeam-python/sunbeam/clusterd/cluster.py +++ b/sunbeam-python/sunbeam/clusterd/cluster.py @@ -253,6 +253,10 @@ class ClusterService(MicroClusterService, ExtendedAPIService): # sucessfully run. Note: this is distinct from microcluster bootstrap. SUNBEAM_BOOTSTRAP_KEY = "sunbeam_bootstrapped" + # This key is used to determine if Juju controller is migrated to k8s + # from lxd. This is used only in local type deployment. + JUJU_CONTROLLER_MIGRATE_KEY = "juju_controller_migrated_to_k8s" + def bootstrap( self, name: str, address: str, role: list[str], machineid: int = -1 ) -> None: @@ -303,3 +307,21 @@ def check_sunbeam_bootstrapped(self) -> bool: except service.ClusterServiceUnavailableException: state = False return state + + def unset_juju_controller_migrated(self) -> None: + """Remove juju controller migrated key.""" + self.update_config(self.JUJU_CONTROLLER_MIGRATE_KEY, json.dumps("False")) + + def set_juju_controller_migrated(self) -> None: + """Mark juju controller as migrated.""" + self.update_config(self.JUJU_CONTROLLER_MIGRATE_KEY, json.dumps("True")) + + def check_juju_controller_migrated(self) -> bool: + """Check if juju controller has been migrated.""" + try: + state = json.loads(self.get_config(self.JUJU_CONTROLLER_MIGRATE_KEY)) + except service.ConfigItemNotFoundException: + state = False + except service.ClusterServiceUnavailableException: + state = False + return state diff --git a/sunbeam-python/sunbeam/core/checks.py b/sunbeam-python/sunbeam/core/checks.py index da858b7f..d38f7790 100644 --- a/sunbeam-python/sunbeam/core/checks.py +++ b/sunbeam-python/sunbeam/core/checks.py @@ -15,6 +15,7 @@ import base64 import enum +import grp import json import logging import os @@ -33,6 +34,7 @@ get_host_total_cores, get_host_total_ram, ) +from sunbeam.core.juju import JujuStepHelper LOG = logging.getLogger(__name__) @@ -553,3 +555,63 @@ def run(self) -> bool: else: self.message = f"Juju controller {self.controller} is not registered." return False + + +class LxdGroupCheck(Check): + """Check if user is member of lxd group.""" + + def __init__(self): + self.user = os.environ.get("USER") + self.group = "lxd" + + super().__init__( + "Check for lxd group membership", + f"Checking if user {self.user} is member of group {self.group}", + ) + + def run(self) -> bool: + """Return false if user is not member of group. + + Checks: + - User is part of group + """ + if self.user not in grp.getgrnam(self.group).gr_mem: + self.message = ( + f"{self.user!r} not part of lxd group" + "Insufficient permissions to run sunbeam commands\n" + f"Add the user {self.user!r} to the {self.group!r} group:\n" + "\n" + f" sudo usermod -a -G {self.group} {self.user}\n" + "\n" + "After this, reload the user groups either via a reboot or by" + f" running 'newgrp {self.group}'." + ) + + return False + + return True + + +class LXDJujuControllerRegistrationCheck(Check): + """Check if lxd juju controller exists.""" + + def __init__(self): + super().__init__( + "Check existence of LXD Juju Controller", + "Checking if lxd juju controller exists", + ) + + def run(self) -> bool: + """Check if lxd juju controller exists.""" + controllers = JujuStepHelper().get_controllers(clouds=["localhost"]) + if len(controllers) == 0: + self.message = ( + "Missing Juju controller on LXD" + "Bootstrap Juju controller on LXD:" + "\n" + " juju bootstrap localhost" + "\n" + ) + return False + + return True diff --git a/sunbeam-python/sunbeam/core/deployment.py b/sunbeam-python/sunbeam/core/deployment.py index 7f28ad48..940c41c3 100644 --- a/sunbeam-python/sunbeam/core/deployment.py +++ b/sunbeam-python/sunbeam/core/deployment.py @@ -314,21 +314,7 @@ def get_manifest(self, manifest_file: pathlib.Path | None = None) -> Manifest: self._manifest = manifest return manifest - def _load_tfhelpers(self): - feature_manager = self.get_feature_manager() - tfvar_map = copy.deepcopy(MANIFEST_ATTRIBUTES_TFVAR_MAP) - tfvar_map_feature = feature_manager.get_all_feature_manifest_tfvar_map() - tfvar_map = sunbeam_utils.merge_dict(tfvar_map, tfvar_map_feature) - - manifest = self.get_manifest() - if not manifest.core.software.terraform: - raise MissingTerraformInfoException("Manifest is missing terraform plans.") - terraform_plans = manifest.core.software.terraform.copy() - for _, feature in manifest.get_features(): - if not feature.software.terraform: - continue - terraform_plans.update(feature.software.terraform.copy()) - + def _get_juju_clusterd_env(self) -> dict: env = {} if self.juju_controller and self.juju_account: env.update( @@ -348,6 +334,25 @@ def _load_tfhelpers(self): "TF_HTTP_CLIENT_PRIVATE_KEY_PEM": self.clusterd_certpair.private_key, # noqa E501 } ) + return env + + def _load_tfhelpers(self): + feature_manager = self.get_feature_manager() + tfvar_map = copy.deepcopy(MANIFEST_ATTRIBUTES_TFVAR_MAP) + tfvar_map_feature = feature_manager.get_all_feature_manifest_tfvar_map() + tfvar_map = sunbeam_utils.merge_dict(tfvar_map, tfvar_map_feature) + + manifest = self.get_manifest() + if not manifest.core.software.terraform: + raise MissingTerraformInfoException("Manifest is missing terraform plans.") + terraform_plans = manifest.core.software.terraform.copy() + for _, feature in manifest.get_features(): + if not feature.software.terraform: + continue + terraform_plans.update(feature.software.terraform.copy()) + + env = {} + env.update(self._get_juju_clusterd_env()) env.update(self.get_proxy_settings()) for tfplan, tf_manifest in terraform_plans.items(): @@ -373,6 +378,12 @@ def plans_directory(self) -> pathlib.Path: snap = Snap() return snap.paths.user_common / "etc" / self.name + def reload_tfhelpers(self): + """Reload tfhelpers to update juju environment variables.""" + env = self._get_juju_clusterd_env() + for tfplan, tfhelper in self._tfhelpers.items(): + tfhelper.reload_env(env) + def get_tfhelper(self, tfplan: str) -> TerraformHelper: """Get an instance of TerraformHelper for the given tfplan. diff --git a/sunbeam-python/sunbeam/core/juju.py b/sunbeam-python/sunbeam/core/juju.py index 1af63d0b..8e39f14a 100644 --- a/sunbeam-python/sunbeam/core/juju.py +++ b/sunbeam-python/sunbeam/core/juju.py @@ -52,6 +52,7 @@ from packaging import version from snaphelpers import Snap +from sunbeam import utils from sunbeam.clusterd.client import Client from sunbeam.core.common import SunbeamException from sunbeam.versions import JUJU_BASE, SUPPORTED_RELEASE @@ -86,6 +87,12 @@ class ControllerNotFoundException(JujuException): pass +class ControllerNotReachableException(JujuException): + """Raised when controller is not reachable.""" + + pass + + class ModelNotFoundException(JujuException): """Raised when model is missing.""" @@ -1369,6 +1376,17 @@ def maas_credential(cloud: str, credential: str, maas_apikey: str): } return credentials + @staticmethod + def empty_credential(cloud: str): + """Create empty credential definition.""" + credentials: dict[str, dict] = {"credentials": {}} + credentials["credentials"][cloud] = { + "empty-creds": { + "auth-type": "empty", + } + } + return credentials + async def get_spaces(self, model: str) -> list[dict]: """Get spaces in model.""" model_impl = await self.get_model(model) @@ -1559,6 +1577,23 @@ def get_controller(self, controller: str) -> dict: LOG.debug(e) raise ControllerNotFoundException() from e + def get_controller_ip(self, controller: str) -> str: + """Get Controller IP of given juju controller. + + Returns Juju Controller IP. + Raises ControllerNotFoundException or ControllerNotReachableException. + """ + controller_details = self.get_controller(controller) + endpoints = controller_details.get("details", {}).get("api-endpoints", []) + controller_ip_port = utils.first_connected_server(endpoints) + if not controller_ip_port: + raise ControllerNotReachableException( + f"Juju Controller {controller} not reachable" + ) + + controller_ip = controller_ip_port.rsplit(":", 1)[0] + return controller_ip + def add_cloud(self, name: str, cloud: dict, controller: str | None) -> bool: """Add cloud to client clouds. @@ -1589,8 +1624,35 @@ def add_cloud(self, name: str, cloud: dict, controller: str | None) -> bool: return True + def add_k8s_cloud_in_client(self, name: str, kubeconfig: dict): + """Add k8s cloud in juju client.""" + with tempfile.NamedTemporaryFile() as temp: + temp.write(yaml.dump(kubeconfig).encode("utf-8")) + temp.flush() + cmd = [ + self._get_juju_binary(), + "add-k8s", + name, + "--client", + "--region=localhost/localhost", + ] + + env = os.environ.copy() + env.update({"KUBECONFIG": temp.name}) + LOG.debug(f'Running command {" ".join(cmd)}') + process = subprocess.run( + cmd, capture_output=True, text=True, check=True, env=env + ) + LOG.debug( + f"Command finished. stdout={process.stdout}, stderr={process.stderr}" + ) + def add_credential(self, cloud: str, credential: dict, controller: str | None): - """Add credential to client credentials.""" + """Add credentials to client or controller. + + If controller is specidifed, credential is added to controller. + If controller is None, credential is added to client. + """ with tempfile.NamedTemporaryFile() as temp: temp.write(yaml.dump(credential).encode("utf-8")) temp.flush() @@ -1600,10 +1662,11 @@ def add_credential(self, cloud: str, credential: dict, controller: str | None): cloud, "--file", temp.name, - "--client", ] if controller: cmd.extend(["--controller", controller]) + else: + cmd.extend(["--client"]) LOG.debug(f'Running command {" ".join(cmd)}') process = subprocess.run(cmd, capture_output=True, text=True, check=True) LOG.debug( diff --git a/sunbeam-python/sunbeam/core/terraform.py b/sunbeam-python/sunbeam/core/terraform.py index 65209af6..02234a71 100644 --- a/sunbeam-python/sunbeam/core/terraform.py +++ b/sunbeam-python/sunbeam/core/terraform.py @@ -143,6 +143,13 @@ def write_terraformrc(self) -> None: ) ) + def reload_env(self, env: dict) -> None: + """Update environment variables.""" + if self.env: + self.env.update(env) + else: + self.env = env + def init(self) -> None: """Terraform init.""" os_env = os.environ.copy() diff --git a/sunbeam-python/sunbeam/provider/local/commands.py b/sunbeam-python/sunbeam/provider/local/commands.py index 3f961cbc..8d88d81c 100644 --- a/sunbeam-python/sunbeam/provider/local/commands.py +++ b/sunbeam-python/sunbeam/provider/local/commands.py @@ -44,6 +44,8 @@ JujuControllerRegistrationCheck, JujuSnapCheck, LocalShareCheck, + LxdGroupCheck, + LXDJujuControllerRegistrationCheck, SshKeysConnectedCheck, SystemRequirementsCheck, TokenCheck, @@ -78,7 +80,8 @@ ModelNotFoundException, run_sync, ) -from sunbeam.core.manifest import AddManifestStep +from sunbeam.core.k8s import K8S_CLOUD_SUFFIX +from sunbeam.core.manifest import AddManifestStep, Manifest from sunbeam.core.openstack import OPENSTACK_MODEL from sunbeam.core.terraform import TerraformInitStep from sunbeam.provider.base import ProviderBase @@ -108,23 +111,27 @@ ) from sunbeam.steps.juju import ( AddCloudJujuStep, + AddCredentialsJujuStep, AddJujuMachineStep, AddJujuModelStep, AddJujuSpaceStep, BackupBootstrapUserStep, - BindJujuApplicationStep, BootstrapJujuStep, CreateJujuUserStep, JujuGrantModelAccessStep, JujuLoginStep, + MigrateModelStep, RegisterJujuUserStep, RemoveJujuMachineStep, SaveControllerStep, + SaveJujuAdminUserLocallyStep, SaveJujuRemoteUserLocallyStep, SaveJujuUserLocallyStep, + SwitchToController, UpdateJujuModelConfigStep, ) from sunbeam.steps.k8s import ( + AddK8SCloudInClientStep, AddK8SCloudStep, AddK8SCredentialStep, AddK8SUnitsStep, @@ -139,6 +146,7 @@ RemoveMicrocephUnitsStep, ) from sunbeam.steps.microk8s import ( + AddMicrok8sCloudInClientStep, AddMicrok8sCloudStep, AddMicrok8sCredentialStep, AddMicrok8sUnitsStep, @@ -165,6 +173,7 @@ LOG = logging.getLogger(__name__) console = Console() DEPLOYMENTS_CONFIG_KEY = "deployments" +DEFAULT_LXD_CLOUD = "localhost" @click.group("cluster", context_settings=CONTEXT_SETTINGS, cls=CatchGroup) @@ -208,6 +217,332 @@ def deployment_type(self) -> Tuple[str, Type[Deployment]]: return LOCAL_TYPE, LocalDeployment +def get_sunbeam_machine_plans( + deployment: Deployment, manifest: Manifest +) -> list[BaseStep]: + plans: list[BaseStep] = [] + client = deployment.get_client() + jhelper = JujuHelper(deployment.get_connected_controller()) + proxy_settings = deployment.get_proxy_settings() + fqdn = utils.get_fqdn() + + sunbeam_machine_tfhelper = deployment.get_tfhelper("sunbeam-machine-plan") + plans.extend( + [ + TerraformInitStep(sunbeam_machine_tfhelper), + DeploySunbeamMachineApplicationStep( + deployment, + client, + sunbeam_machine_tfhelper, + jhelper, + manifest, + deployment.openstack_machines_model, + refresh=True, + proxy_settings=proxy_settings, + ), + AddSunbeamMachineUnitsStep( + client, fqdn, jhelper, deployment.openstack_machines_model + ), + ] + ) + + return plans + + +def get_k8s_plans( + deployment: Deployment, manifest: Manifest, k8s_provider: str, accept_defaults: bool +) -> list[BaseStep]: + plans: list[BaseStep] = [] + client = deployment.get_client() + jhelper = JujuHelper(deployment.get_connected_controller()) + fqdn = utils.get_fqdn() + + if k8s_provider == "k8s": + k8s_tfhelper = deployment.get_tfhelper("k8s-plan") + plans.extend( + [ + TerraformInitStep(k8s_tfhelper), + DeployK8SApplicationStep( + deployment, + client, + k8s_tfhelper, + jhelper, + manifest, + deployment.openstack_machines_model, + accept_defaults=accept_defaults, + ), + AddK8SUnitsStep( + client, fqdn, jhelper, deployment.openstack_machines_model + ), + StoreK8SKubeConfigStep( + client, jhelper, deployment.openstack_machines_model + ), + ] + ) + else: + k8s_tfhelper = deployment.get_tfhelper("microk8s-plan") + plans.extend( + [ + TerraformInitStep(k8s_tfhelper), + DeployMicrok8sApplicationStep( + deployment, + client, + k8s_tfhelper, + jhelper, + manifest, + deployment.openstack_machines_model, + accept_defaults=accept_defaults, + ), + AddMicrok8sUnitsStep( + client, fqdn, jhelper, deployment.openstack_machines_model + ), + StoreMicrok8sConfigStep( + client, jhelper, deployment.openstack_machines_model + ), + ] + ) + + return plans + + +def get_juju_controller_plans( + deployment: Deployment, + controller: str, + data_location: Path, + external_controller: bool = False, +) -> list[BaseStep]: + """Get Juju controller related plans. + + Plans include the following: + * Add cloud to juju controller + * Add credentials if required to juju controller + * Update Juju controller details to cluster db + * Save Juju credentials locally + """ + client = deployment.get_client() + controller_ip = JujuStepHelper().get_controller_ip(controller) + cloud_definition = JujuHelper.manual_cloud(deployment.name, controller_ip) + credential_definition = JujuHelper.empty_credential(deployment.name) + + if external_controller: + return [ + AddCloudJujuStep(deployment.name, cloud_definition, controller), + # Not creating Juju user in external controller case because of juju bug + # https://bugs.launchpad.net/juju/+bug/2073741 + ClusterUpdateJujuControllerStep(client, deployment.controller, True), + SaveJujuRemoteUserLocallyStep(controller, data_location), + ] + else: + return [ + AddCloudJujuStep(deployment.name, cloud_definition, controller), + AddCredentialsJujuStep( + deployment.name, "empty-creds", credential_definition, controller + ), + BackupBootstrapUserStep("admin", data_location), + ClusterUpdateJujuControllerStep(client, controller, False, False), + SaveJujuAdminUserLocallyStep(controller, data_location), + ] + + +def get_juju_model_machine_plans( + deployment: Deployment, + jhelper: JujuHelper, + local_management_ip: str, + credential_name: str | None, +) -> list[BaseStep]: + """Get Juju model and machine related plans.""" + proxy_settings = deployment.get_proxy_settings() + return [ + AddJujuModelStep( + jhelper, + deployment.openstack_machines_model, + deployment.name, + credential_name, + proxy_settings, + ), + AddJujuMachineStep( + local_management_ip, deployment.openstack_machines_model, jhelper + ), + ] + + +def get_juju_spaces_plans( + deployment: Deployment, + jhelper: JujuHelper, + management_cidr: str, +) -> list[BaseStep]: + """Get Juju spaces related plans.""" + return [ + AddJujuSpaceStep( + jhelper, + deployment.openstack_machines_model, + deployment.get_space(Networks.MANAGEMENT), + [management_cidr], + ), + UpdateJujuModelConfigStep( + jhelper, + deployment.openstack_machines_model, + { + "default-space": deployment.get_space(Networks.MANAGEMENT), + }, + ), + ] + + +def get_juju_migrate_plans( + deployment: Deployment, + from_controller: str, + to_controller: str, + data_location: Path, +) -> list[BaseStep]: + """Get Juju migrate related plans.""" + client = deployment.get_client() + controller_ip = JujuStepHelper().get_controller_ip(to_controller) + cloud_definition = JujuHelper.manual_cloud(deployment.name, controller_ip) + credential_definition = JujuHelper.empty_credential(deployment.name) + + return [ + SwitchToController(deployment.controller), + AddCloudJujuStep(deployment.name, cloud_definition, deployment.controller), + AddCredentialsJujuStep( + deployment.name, + "empty-creds", + credential_definition, + deployment.controller, + ), + MigrateModelStep( + deployment.openstack_machines_model, + from_controller, + to_controller, + ), + ClusterUpdateJujuControllerStep(client, deployment.controller, False, False), + SaveJujuAdminUserLocallyStep(deployment.controller, data_location), + ] + + +def get_juju_user_plans( + deployment: Deployment, + jhelper: JujuHelper, + data_location: Path, + token: str, +) -> list[BaseStep]: + """Get Juju User related plans.""" + client = deployment.get_client() + fqdn = utils.get_fqdn() + + return [ + ClusterAddJujuUserStep(client, fqdn, token), + JujuGrantModelAccessStep(jhelper, fqdn, deployment.openstack_machines_model), + BackupBootstrapUserStep(fqdn, data_location), + SaveJujuUserLocallyStep(fqdn, data_location), + RegisterJujuUserStep( + client, fqdn, deployment.controller, data_location, replace=True + ), + ] + + +def get_juju_bootstrap_plans( + deployment: Deployment, + jhelper: JujuHelper, + bootstrap_args: list, + k8s_provider: str, +): + """Get Juju Bootstrap related plans.""" + client = deployment.get_client() + proxy_settings = deployment.get_proxy_settings() + bootstrap_args.extend(["--config", "controller-service-type=loadbalancer"]) + + plan: list[BaseStep] = [] + if k8s_provider == "k8s": + plan.append(AddK8SCloudInClientStep(deployment, jhelper)) + else: + plan.append(AddMicrok8sCloudInClientStep(deployment, jhelper)) + + plan.append( + BootstrapJujuStep( + client, + f"{deployment.name}{K8S_CLOUD_SUFFIX}", + "k8s", + deployment.controller, + bootstrap_args=bootstrap_args, + proxy_settings=proxy_settings, + ) + ) + return plan + + +def deploy_and_migrate_juju_controller( + deployment: LocalDeployment, + manifest: Manifest, + local_management_ip: str, + management_cidr: str, + data_location: Path, + k8s_provider: str, + accept_defaults: bool, + show_hints: bool = False, +): + """Deploy LXD controller and migrate to k8s.""" + plan1: list[BaseStep] = [] + plan2: list[BaseStep] = [] + plan3: list[BaseStep] = [] + plan4: list[BaseStep] = [] + plan5: list[BaseStep] = [] + plan6: list[BaseStep] = [] + + client = deployment.get_client() + fqdn = utils.get_fqdn() + juju_bootstrap_args = [] + if manifest.core.software.juju.bootstrap_args: + juju_bootstrap_args = manifest.core.software.juju.bootstrap_args.copy() + + # Atmost one controller will be returned as one cloud is passed as argument. + # lxd_controllers cannot be empty list since this is verified in preflight check. + lxd_controllers = JujuStepHelper().get_controllers([DEFAULT_LXD_CLOUD]) + lxd_controller = lxd_controllers[0] + + if not client.cluster.check_juju_controller_migrated(): + plan1 = get_juju_controller_plans( + deployment, lxd_controller, data_location, external_controller=False + ) + run_plan(plan1, console, show_hints) + + # Reload deployment with lxd controller admin user credentials + deployment.reload_credentials() + jhelper = JujuHelper(deployment.get_connected_controller()) + + plan2 = get_juju_model_machine_plans( + deployment, jhelper, local_management_ip, "empty-creds" + ) + run_plan(plan2, console, show_hints) + + plan3 = get_juju_spaces_plans(deployment, jhelper, management_cidr) + plan3.extend(get_sunbeam_machine_plans(deployment, manifest)) + plan3.extend(get_k8s_plans(deployment, manifest, k8s_provider, accept_defaults)) + plan3.extend( + get_juju_bootstrap_plans(deployment, jhelper, juju_bootstrap_args, k8s_provider) + ) + run_plan(plan3, console, show_hints) + + plan4 = get_juju_migrate_plans( + deployment, lxd_controller, deployment.controller, data_location + ) + run_plan(plan4, console, show_hints) + client.cluster.set_juju_controller_migrated() + + # Reload deployment with sunbeam-controller admin user credentials + deployment.reload_credentials() + jhelper = JujuHelper(deployment.get_connected_controller()) + + plan5 = [ + CreateJujuUserStep(fqdn), + ] + plan5_results = run_plan(plan5, console, show_hints) + token = get_step_message(plan5_results, CreateJujuUserStep) + + plan6 = get_juju_user_plans(deployment, jhelper, data_location, token) + run_plan(plan6, console, show_hints) + + @click.command() @click.option("-a", "--accept-defaults", help="Accept all defaults.", is_flag=True) @click.option( @@ -295,7 +630,6 @@ def bootstrap( LOG.debug(f"Bootstrap node: roles {roles_str}") k8s_provider = snap.config.get("k8s.provider") - juju_bootstrap_args = manifest.core.software.juju.bootstrap_args data_location = snap.paths.user_data preflight_checks: list[Check] = [] @@ -313,6 +647,9 @@ def bootstrap( preflight_checks.append( JujuControllerRegistrationCheck(juju_controller, data_location) ) + else: + preflight_checks.append(LxdGroupCheck()) + preflight_checks.append(LXDJujuControllerRegistrationCheck()) run_preflight_checks(preflight_checks, console) @@ -339,20 +676,6 @@ def bootstrap( ) local_management_ip = utils.get_local_cidr_by_default_route() - if juju_controller: - controller_details = JujuStepHelper().get_controller(juju_controller) - endpoints = controller_details.get("details", {}).get("api-endpoints", []) - controller_ip_port = utils.first_connected_server(endpoints) - if not controller_ip_port: - raise click.ClickException("Juju Controller not reachable") - - controller_ip = controller_ip_port.rsplit(":", 1)[0] - else: - controller_ip = local_management_ip - - LOG.debug(f"Juju Controller IP: {controller_ip}") - cloud_definition = JujuHelper.manual_cloud(deployment.name, controller_ip) - plan: list[BaseStep] = [] plan.append( SaveControllerStep( @@ -373,223 +696,73 @@ def bootstrap( deployment, accept_defaults=accept_defaults, manifest=manifest ) ) + plan.append(PromptRegionStep(client, manifest, accept_defaults)) run_plan(plan, console, show_hints) update_config(client, DEPLOYMENTS_CONFIG_KEY, deployments.get_minimal_info()) proxy_settings = deployment.get_proxy_settings() LOG.debug(f"Proxy settings: {proxy_settings}") - plan1: list[BaseStep] - plan2: list[BaseStep] - plan3: list[BaseStep] - plan4: list[BaseStep] if juju_controller: - plan1 = [] - plan1.append( - AddCloudJujuStep(deployment.name, cloud_definition, juju_controller) - ) - run_plan(plan1, console, show_hints) + plan11: list[BaseStep] = [] + plan12: list[BaseStep] = [] + plan13: list[BaseStep] = [] - # Not creating Juju user in external controller case because of below juju bug - # https://bugs.launchpad.net/juju/+bug/2073741 - plan2 = [] - plan2.append( - ClusterUpdateJujuControllerStep(client, deployment.controller, True) + plan11 = get_juju_controller_plans( + deployment, juju_controller, data_location, external_controller=True ) - plan2.append(SaveJujuRemoteUserLocallyStep(juju_controller, data_location)) - run_plan(plan2, console, show_hints) + run_plan(plan11, console, show_hints) deployment.reload_credentials() jhelper = JujuHelper(deployment.get_connected_controller()) - plan3 = [] - plan3.append( - AddJujuModelStep( - jhelper, - deployment.openstack_machines_model, - deployment.name, - None, - proxy_settings, - ) - ) - plan3.append( - AddJujuMachineStep( - local_management_ip, deployment.openstack_machines_model, jhelper - ) + plan12 = get_juju_model_machine_plans( + deployment, jhelper, local_management_ip, None ) - run_plan(plan3, console, show_hints) + run_plan(plan12, console, show_hints) - plan4 = [] - plan4.append( - AddJujuSpaceStep( - jhelper, - deployment.openstack_machines_model, - deployment.get_space(Networks.MANAGEMENT), - [management_cidr], - ) - ) - plan4.append( - UpdateJujuModelConfigStep( - jhelper, - deployment.openstack_machines_model, - { - "default-space": deployment.get_space(Networks.MANAGEMENT), - }, - ) + plan13 = get_juju_spaces_plans(deployment, jhelper, management_cidr) + plan13.extend(get_sunbeam_machine_plans(deployment, manifest)) + plan13.extend( + get_k8s_plans(deployment, manifest, k8s_provider, accept_defaults) ) + if k8s_provider == "k8s": + plan13.append(AddK8SCloudStep(deployment, jhelper)) + else: + plan13.append(AddMicrok8sCloudStep(deployment, jhelper)) + run_plan(plan13, console, show_hints) else: - plan1 = [] - plan1.append( - AddCloudJujuStep(deployment.name, cloud_definition, juju_controller) - ) - plan1.append( - BootstrapJujuStep( - client, - deployment.name, - cloud_definition["clouds"][deployment.name]["type"], - deployment.controller, - bootstrap_args=juju_bootstrap_args, - proxy_settings=proxy_settings, - ) - ) - run_plan(plan1, console, show_hints) - - plan2 = [] - plan2.append(CreateJujuUserStep(fqdn)) - plan2.append( - ClusterUpdateJujuControllerStep(client, deployment.controller, False) - ) - plan2_results = run_plan(plan2, console, show_hints) + plan21: list[BaseStep] = [] - token = get_step_message(plan2_results, CreateJujuUserStep) - - plan3 = [] - plan3.append(ClusterAddJujuUserStep(client, fqdn, token)) - plan3.append(BackupBootstrapUserStep(fqdn, data_location)) - plan3.append(SaveJujuUserLocallyStep(fqdn, data_location)) - plan3.append( - RegisterJujuUserStep( - client, fqdn, deployment.controller, data_location, replace=True - ) + deploy_and_migrate_juju_controller( + deployment, + manifest, + local_management_ip, + management_cidr, + data_location, + k8s_provider, + accept_defaults, + show_hints, ) - run_plan(plan3, console, show_hints) + # Reload deployment with sunbeam-controller {fqdn} user credentials deployment.reload_credentials() + deployments.write() + deployment.reload_tfhelpers() jhelper = JujuHelper(deployment.get_connected_controller()) - plan4 = [] - plan4.append( - AddJujuSpaceStep( - jhelper, - deployment.openstack_machines_model, - deployment.get_space(Networks.MANAGEMENT), - [management_cidr], - ) - ) - plan4.append( - UpdateJujuModelConfigStep( - jhelper, - deployment.openstack_machines_model, - { - "default-space": deployment.get_space(Networks.MANAGEMENT), - }, - ) - ) - plan4.append( - # TODO(gboutry): fix when LP#2067617 is released - # This should be replaced by a juju controller set config - # when the previous bug is fixed - # Binding controller's endpoints to the management space - BindJujuApplicationStep( - jhelper, - deployment.openstack_machines_model, - "controller", - deployment.get_space(Networks.MANAGEMENT), - ) - ) - plan4.append( - SaveControllerStep( - deployment.controller, - deployment.name, - deployments, - data_location, - bool(juju_controller), - force=True, - ) - ) - plan4.append(PromptRegionStep(client, manifest, accept_defaults)) - # Deploy sunbeam machine charm - sunbeam_machine_tfhelper = deployment.get_tfhelper("sunbeam-machine-plan") - plan4.append(TerraformInitStep(sunbeam_machine_tfhelper)) - plan4.append( - DeploySunbeamMachineApplicationStep( - deployment, - client, - sunbeam_machine_tfhelper, - jhelper, - manifest, - deployment.openstack_machines_model, - refresh=True, - proxy_settings=proxy_settings, - ) - ) - plan4.append( - AddSunbeamMachineUnitsStep( - client, fqdn, jhelper, deployment.openstack_machines_model - ) - ) + if k8s_provider == "k8s": + plan21.append(AddK8SCredentialStep(deployment, jhelper)) + else: + plan21.append(AddMicrok8sCredentialStep(deployment, jhelper)) - if k8s_provider == "k8s": - k8s_tfhelper = deployment.get_tfhelper("k8s-plan") - plan4.append(TerraformInitStep(k8s_tfhelper)) - plan4.append( - DeployK8SApplicationStep( - deployment, - client, - k8s_tfhelper, - jhelper, - manifest, - deployment.openstack_machines_model, - accept_defaults=accept_defaults, - ) - ) - plan4.append( - AddK8SUnitsStep(client, fqdn, jhelper, deployment.openstack_machines_model) - ) - plan4.append( - StoreK8SKubeConfigStep(client, jhelper, deployment.openstack_machines_model) - ) - plan4.append(AddK8SCloudStep(deployment, jhelper)) - else: - k8s_tfhelper = deployment.get_tfhelper("microk8s-plan") - plan4.append(TerraformInitStep(k8s_tfhelper)) - plan4.append( - DeployMicrok8sApplicationStep( - deployment, - client, - k8s_tfhelper, - jhelper, - manifest, - deployment.openstack_machines_model, - accept_defaults=accept_defaults, - ) - ) - plan4.append( - AddMicrok8sUnitsStep( - client, fqdn, jhelper, deployment.openstack_machines_model - ) - ) - plan4.append( - StoreMicrok8sConfigStep( - client, jhelper, deployment.openstack_machines_model - ) - ) - plan4.append(AddMicrok8sCloudStep(deployment, jhelper)) + run_plan(plan21, console, show_hints) + plan1: list[BaseStep] = [] # Deploy Microceph application during bootstrap irrespective of node role. microceph_tfhelper = deployment.get_tfhelper("microceph-plan") - plan4.append(TerraformInitStep(microceph_tfhelper)) - plan4.append( + plan1.append(TerraformInitStep(microceph_tfhelper)) + plan1.append( DeployMicrocephApplicationStep( deployment, client, @@ -601,12 +774,12 @@ def bootstrap( ) if is_storage_node: - plan4.append( + plan1.append( AddMicrocephUnitsStep( client, fqdn, jhelper, deployment.openstack_machines_model ) ) - plan4.append( + plan1.append( ConfigureMicrocephOSDStep( client, fqdn, @@ -619,8 +792,8 @@ def bootstrap( openstack_tfhelper = deployment.get_tfhelper("openstack-plan") if is_control_node: - plan4.append(TerraformInitStep(openstack_tfhelper)) - plan4.append( + plan1.append(TerraformInitStep(openstack_tfhelper)) + plan1.append( DeployControlPlaneStep( deployment, openstack_tfhelper, @@ -635,7 +808,7 @@ def bootstrap( # Redeploy of Microceph is required to fill terraform vars # related to traefik-rgw/keystone-endpoints offers from # openstack model - plan4.append( + plan1.append( DeployMicrocephApplicationStep( deployment, client, @@ -647,19 +820,19 @@ def bootstrap( ) ) - run_plan(plan4, console, show_hints) + run_plan(plan1, console, show_hints) - plan5: list[BaseStep] = [] + plan2: list[BaseStep] = [] if is_control_node: - plan5.append(OpenStackPatchLoadBalancerServicesIPStep(client)) + plan2.append(OpenStackPatchLoadBalancerServicesIPStep(client)) # NOTE(jamespage): # As with MicroCeph, always deploy the openstack-hypervisor charm # and add a unit to the bootstrap node if required. hypervisor_tfhelper = deployment.get_tfhelper("hypervisor-plan") - plan5.append(TerraformInitStep(hypervisor_tfhelper)) - plan5.append( + plan2.append(TerraformInitStep(hypervisor_tfhelper)) + plan2.append( DeployHypervisorApplicationStep( deployment, client, @@ -671,14 +844,14 @@ def bootstrap( ) ) if is_compute_node: - plan5.append( + plan2.append( AddHypervisorUnitsStep( client, fqdn, jhelper, deployment.openstack_machines_model ) ) - plan5.append(SetBootstrapped(client)) - run_plan(plan5, console, show_hints) + plan2.append(SetBootstrapped(client)) + run_plan(plan2, console, show_hints) click.echo(f"Node has been bootstrapped with roles: {pretty_roles}") diff --git a/sunbeam-python/sunbeam/provider/local/deployment.py b/sunbeam-python/sunbeam/provider/local/deployment.py index e022893c..c42a3ff8 100644 --- a/sunbeam-python/sunbeam/provider/local/deployment.py +++ b/sunbeam-python/sunbeam/provider/local/deployment.py @@ -124,10 +124,7 @@ def reload_credentials(self): @property def openstack_machines_model(self) -> str: """Return the openstack machines model name.""" - if self.juju_controller and self.juju_controller.is_external: - return "openstack-machines" - - return "controller" + return "openstack-machines" @property def controller(self) -> str: diff --git a/sunbeam-python/sunbeam/steps/clusterd.py b/sunbeam-python/sunbeam/steps/clusterd.py index c607ed86..b8065e5c 100644 --- a/sunbeam-python/sunbeam/steps/clusterd.py +++ b/sunbeam-python/sunbeam/steps/clusterd.py @@ -467,9 +467,22 @@ def run(self, status: Status | None = None) -> Result: class ClusterUpdateJujuControllerStep(BaseStep, JujuStepHelper): - """Save Juju controller in cluster database.""" + """Save Juju controller in cluster database. - def __init__(self, client: Client, controller: str, is_external: bool = False): + The controller IP is filtered based on the management_cidr + saved in the cluster database by default. + If filter_endpoints is False, the controller IP is not + filtered and all the controller IPs are saved in cluster + database. + """ + + def __init__( + self, + client: Client, + controller: str, + is_external: bool = False, + filter_endpoints: bool = True, + ): super().__init__( "Add Juju controller to cluster DB", "Adding Juju controller to cluster database", @@ -478,6 +491,7 @@ def __init__(self, client: Client, controller: str, is_external: bool = False): self.client = client self.controller = controller self.is_external = is_external + self.filter_endpoints = filter_endpoints def _extract_ip(self, ip) -> ipaddress.IPv4Address | ipaddress.IPv6Address: """Extract ip from ipv4 or ipv6 ip:port.""" @@ -513,9 +527,16 @@ def is_skip(self, status: Status | None = None) -> Result: :return: ResultType.SKIPPED if the Step should be skipped, ResultType.COMPLETED or ResultType.FAILED otherwise """ + self.controller_details = self.get_controller(self.controller)["details"] + try: variables = questions.load_answers(self.client, BOOTSTRAP_CONFIG_KEY) self.networks = variables.get("bootstrap", {}).get("management_cidr") + + # Do not filter api endpoints based on management cidr + if not self.filter_endpoints: + self.networks = None + juju_controller = JujuController.load(self.client) LOG.debug(f"Controller(s) present at: {juju_controller.api_endpoints}") if not juju_controller.api_endpoints: @@ -526,7 +547,7 @@ def is_skip(self, status: Status | None = None) -> Result: return Result(ResultType.COMPLETED) if juju_controller.api_endpoints == self.filter_ips( - juju_controller.api_endpoints, self.networks + self.controller_details["api-endpoints"], self.networks ): # Controller found, and parsed successfully return Result(ResultType.SKIPPED) @@ -543,12 +564,12 @@ def is_skip(self, status: Status | None = None) -> Result: def run(self, status: Status | None = None) -> Result: """Save controller in sunbeam cluster.""" - controller = self.get_controller(self.controller)["details"] - juju_controller = JujuController( name=self.controller, - api_endpoints=self.filter_ips(controller["api-endpoints"], self.networks), - ca_cert=controller["ca-cert"], + api_endpoints=self.filter_ips( + self.controller_details["api-endpoints"], self.networks + ), + ca_cert=self.controller_details["ca-cert"], is_external=self.is_external, ) try: diff --git a/sunbeam-python/sunbeam/steps/juju.py b/sunbeam-python/sunbeam/steps/juju.py index d6c1b038..218f7c5d 100644 --- a/sunbeam-python/sunbeam/steps/juju.py +++ b/sunbeam-python/sunbeam/steps/juju.py @@ -270,10 +270,7 @@ def run(self, status: Status | None = None) -> Result: :return: """ try: - cmd = [ - self._get_juju_binary(), - "bootstrap", - ] + cmd = [self._get_juju_binary(), "bootstrap"] cmd.extend(self.bootstrap_args) cmd.extend([self.cloud, self.controller]) if "HTTP_PROXY" in self.proxy_settings: @@ -305,9 +302,11 @@ def run(self, status: Status | None = None) -> Result: option, _ = arg.split("=") arg = "=".join((option, "********")) hidden_cmd.append(arg) + LOG.debug(f'Running command {" ".join(hidden_cmd)}') env = os.environ.copy() env.update(self.proxy_settings) + process = subprocess.run( cmd, capture_output=True, text=True, check=True, env=env ) @@ -1157,8 +1156,9 @@ def is_skip(self, status: Status | None = None) -> Result: try: juju_account = JujuAccount.load(self.data_location) LOG.debug(f"Local account found: {juju_account.user}") - # TODO(gboutry): make user password updateable ? - return Result(ResultType.SKIPPED) + if juju_account.user == self.username: + # TODO(gboutry): make user password updateable ? + return Result(ResultType.SKIPPED) except JujuAccountNotFound: LOG.debug("Local account not found") pass @@ -1204,6 +1204,63 @@ def run(self, status: Status | None = None) -> Result: return Result(ResultType.COMPLETED) +class SaveJujuAdminUserLocallyStep(BaseStep): + """Save juju admin user locally in accounts yaml file. + + Save only if the current user is admin. + """ + + def __init__(self, controller: str, data_location: Path): + super().__init__("Save Admin User", "Saving admin user for local usage") + self.controller = controller + self.data_location = data_location + self.home = os.environ.get("SNAP_REAL_HOME") + + def _get_credentials_from_local_juju( + self, controller: str + ) -> tuple[str, str] | None: + """Get user name from local juju accounts file.""" + try: + with open(f"{self.home}/.local/share/juju/accounts.yaml") as f: + accounts = yaml.safe_load(f) + user = ( + accounts.get("controllers", {}).get(self.controller, {}).get("user") + ) + password = ( + accounts.get("controllers", {}) + .get(self.controller, {}) + .get("password") + ) + LOG.debug(f"Found user from accounts.yaml for {controller}: {user}") + return user, password + except FileNotFoundError as e: + LOG.debug(f"Error in retrieving local user: {str(e)}") + user = None + + return None + + def run(self, status: Status | None = None) -> Result: + """Run the step to completion. + + Invoked when the step is run and returns a ResultType to indicate + + :return: + """ + account = self._get_credentials_from_local_juju(self.controller) + if account is None: + return Result(ResultType.FAILED, "Error in retrieving Juju acccount") + + user = account[0] + if user == "admin": + juju_account = JujuAccount( + user=user, + password=account[1], + ) + juju_account.write(self.data_location) + + return Result(ResultType.COMPLETED) + + class WriteJujuStatusStep(BaseStep, JujuStepHelper): """Get the status of the specified model.""" @@ -2078,3 +2135,98 @@ def run(self, status: Status | None = None) -> Result: LOG.debug("Removing remote application on %s", saas) run_sync(model.remove_saas(saas)) return Result(ResultType.COMPLETED) + + +class MigrateModelStep(BaseStep, JujuStepHelper): + """Migrate model to another controller in juju.""" + + def __init__( + self, + model: str, + from_controller: str, + to_controller: str, + ): + super().__init__( + "Migrate the model", + f"Migrating model {model} to juju controller {to_controller}", + ) + self.model = model + self.from_controller = from_controller + self.to_controller = to_controller + + def _switch_controller(self, controller: str): + cmd = [self._get_juju_binary(), "switch", controller] + LOG.debug(f'Running command {" ".join(cmd)}') + process = subprocess.run(cmd, capture_output=True, text=True, check=True) + LOG.debug(f"Command finished. stdout={process.stdout}, stderr={process.stderr}") + + @tenacity.retry( + wait=tenacity.wait_fixed(10), + stop=tenacity.stop_after_delay(300), + retry=tenacity.retry_if_exception_type(ValueError), + reraise=True, + ) + def _wait_for_model(self, model: str) -> bool: + models = self._juju_cmd("models") + LOG.debug(f"Models: {models}") + models = models.get("models", []) + LOG.debug(f"models: {models} .. looking for {model}") + for model_ in models: + if model_.get("short-name") == model: + return True + + raise ValueError(f"No model {model} found") + + def is_skip(self, status: Status | None = None) -> Result: + """Determines if the step should be skipped or not.""" + try: + self._switch_controller(self.to_controller) + except subprocess.CalledProcessError as e: + LOG.exception(f"Error in switching to controller {self.to_controller}") + return Result(ResultType.FAILED, str(e)) + + models = self._juju_cmd("models") + LOG.debug(f"Models: {models}") + models = models.get("models", []) + LOG.debug(f"models: {models} .. looking for {self.model}") + for model_ in models: + if model_.get("short-name") == self.model: + return Result(ResultType.SKIPPED) + + return Result(ResultType.COMPLETED) + + def run(self, status: Status | None = None) -> Result: + """Model mirgate and switch to new controller.""" + try: + self._switch_controller(self.from_controller) + except subprocess.CalledProcessError as e: + LOG.exception(f"Error in switching to controller {self.from_controller}") + return Result(ResultType.FAILED, str(e)) + + try: + cmd = [self._get_juju_binary(), "migrate", self.model, self.to_controller] + LOG.debug(f'Running command {" ".join(cmd)}') + process = subprocess.run(cmd, capture_output=True, text=True, check=True) + LOG.debug( + f"Command finished. stdout={process.stdout}, stderr={process.stderr}" + ) + except subprocess.CalledProcessError as e: + LOG.exception( + f"Error in migrating model {self.model} from {self.from_controller}" + f"to controller {self.to_controller}" + ) + return Result(ResultType.FAILED, str(e)) + + try: + self._switch_controller(self.to_controller) + except subprocess.CalledProcessError as e: + LOG.exception(f"Error in switching to controller {self.to_controller}") + return Result(ResultType.FAILED, str(e)) + + try: + # If the model is visible in to_controller, consider migration is completed. + self._wait_for_model(self.model) + except ValueError as e: + return Result(ResultType.FAILED, str(e)) + + return Result(ResultType.COMPLETED) diff --git a/sunbeam-python/sunbeam/steps/k8s.py b/sunbeam-python/sunbeam/steps/k8s.py index 594e170e..f994f085 100644 --- a/sunbeam-python/sunbeam/steps/k8s.py +++ b/sunbeam-python/sunbeam/steps/k8s.py @@ -317,6 +317,41 @@ def run(self, status: Status | None = None) -> Result: return Result(ResultType.COMPLETED) +class AddK8SCloudInClientStep(BaseStep, JujuStepHelper): + _KUBECONFIG = K8S_KUBECONFIG_KEY + + def __init__(self, deployment: Deployment, jhelper: JujuHelper): + super().__init__("Add K8S cloud in client", "Adding K8S cloud to Juju client") + self.client = deployment.get_client() + self.jhelper = jhelper + self.cloud_name = f"{deployment.name}{K8S_CLOUD_SUFFIX}" + self.credential_name = f"{self.cloud_name}{CREDENTIAL_SUFFIX}" + + def is_skip(self, status: Status | None = None) -> Result: + """Determines if the step should be skipped or not. + + :return: ResultType.SKIPPED if the Step should be skipped, + ResultType.COMPLETED or ResultType.FAILED otherwise + """ + clouds = self.get_clouds("k8s", local=True) + LOG.debug(f"Clouds registered in the client: {clouds}") + if self.cloud_name in clouds: + return Result(ResultType.SKIPPED) + + return Result(ResultType.COMPLETED) + + def run(self, status: Status | None = None) -> Result: + """Add microk8s clouds to Juju controller.""" + try: + kubeconfig = read_config(self.client, self._KUBECONFIG) + self.add_k8s_cloud_in_client(self.cloud_name, kubeconfig) + except (ConfigItemNotFoundException, UnsupportedKubeconfigException) as e: + LOG.debug("Failed to add k8s cloud to Juju client", exc_info=True) + return Result(ResultType.FAILED, str(e)) + + return Result(ResultType.COMPLETED) + + class UpdateK8SCloudStep(BaseStep, JujuStepHelper): _KUBECONFIG = K8S_KUBECONFIG_KEY diff --git a/sunbeam-python/sunbeam/steps/microk8s.py b/sunbeam-python/sunbeam/steps/microk8s.py index d4b57eaf..7c0229e8 100644 --- a/sunbeam-python/sunbeam/steps/microk8s.py +++ b/sunbeam-python/sunbeam/steps/microk8s.py @@ -37,12 +37,9 @@ JujuHelper, JujuStepHelper, LeaderNotFoundException, - UnsupportedKubeconfigException, run_sync, ) from sunbeam.core.k8s import ( - CREDENTIAL_SUFFIX, - K8S_CLOUD_SUFFIX, LOADBALANCER_QUESTION_DESCRIPTION, MICROK8S_KUBECONFIG_KEY, ) @@ -53,6 +50,8 @@ ) from sunbeam.core.terraform import TerraformHelper from sunbeam.steps.k8s import ( + AddK8SCloudInClientStep, + AddK8SCloudStep, AddK8SCredentialStep, CheckMysqlK8SDistributionStep, CheckOvnK8SDistributionStep, @@ -214,46 +213,12 @@ class RemoveMicrok8sUnitsStep(RemoveK8SUnitsStep): _K8S_UNIT_TIMEOUT = MICROK8S_UNIT_TIMEOUT -class AddMicrok8sCloudStep(BaseStep, JujuStepHelper): - _CONFIG = MICROK8S_KUBECONFIG_KEY - - def __init__(self, deployment: Deployment, jhelper: JujuHelper): - super().__init__( - "Add MicroK8S cloud", "Adding MicroK8S cloud to Juju controller" - ) - self.client = deployment.get_client() - self.jhelper = jhelper - self.cloud_name = f"{deployment.name}{K8S_CLOUD_SUFFIX}" - self.credential_name = f"{self.cloud_name}{CREDENTIAL_SUFFIX}" - - def is_skip(self, status: Status | None = None) -> Result: - """Determines if the step should be skipped or not. - - :return: ResultType.SKIPPED if the Step should be skipped, - ResultType.COMPLETED or ResultType.FAILED otherwise - """ - clouds = run_sync(self.jhelper.get_clouds()) - LOG.debug(f"Clouds registered in the controller: {clouds}") - # TODO(hemanth): Need to check if cloud credentials are also created? - if f"cloud-{self.cloud_name}" in clouds.keys(): - return Result(ResultType.SKIPPED) - - return Result(ResultType.COMPLETED) +class AddMicrok8sCloudStep(AddK8SCloudStep): + _KUBECONFIG = MICROK8S_KUBECONFIG_KEY - def run(self, status: Status | None = None) -> Result: - """Add microk8s clouds to Juju controller.""" - try: - kubeconfig = read_config(self.client, self._CONFIG) - run_sync( - self.jhelper.add_k8s_cloud( - self.cloud_name, self.credential_name, kubeconfig - ) - ) - except (ConfigItemNotFoundException, UnsupportedKubeconfigException) as e: - LOG.debug("Failed to add k8s cloud to Juju controller", exc_info=True) - return Result(ResultType.FAILED, str(e)) - return Result(ResultType.COMPLETED) +class AddMicrok8sCloudInClientStep(AddK8SCloudInClientStep): + _KUBECONFIG = MICROK8S_KUBECONFIG_KEY class UpdateMicroK8SCloudStep(UpdateK8SCloudStep): diff --git a/sunbeam-python/sunbeam/utils.py b/sunbeam-python/sunbeam/utils.py index d2081328..b3085bf5 100644 --- a/sunbeam-python/sunbeam/utils.py +++ b/sunbeam-python/sunbeam/utils.py @@ -307,7 +307,7 @@ def first_connected_server(servers: list) -> str | None: LOG.debug(f"Server {server} not in : format") continue - ip = ipaddress.ip_address(ip_port[0]) + ip = ipaddress.ip_address(ip_port[0].lstrip("[").rstrip("]")) port = int(ip_port[1]) try: diff --git a/sunbeam-python/tests/unit/sunbeam/core/test_checks.py b/sunbeam-python/tests/unit/sunbeam/core/test_checks.py index 7a188095..3396dd12 100644 --- a/sunbeam-python/tests/unit/sunbeam/core/test_checks.py +++ b/sunbeam-python/tests/unit/sunbeam/core/test_checks.py @@ -14,6 +14,7 @@ # limitations under the License. import base64 +import grp import json import os from unittest.mock import Mock @@ -291,3 +292,51 @@ def test_run_valid_token(self): result = check.run() assert result is True + + +class TestLxdGroupCheck: + def test_user_in_lxd_group(self, mocker): + user = os.environ.get("USER") + grp_struct = Mock() + mocker.patch.object(grp, "getgrnam", return_value=grp_struct) + grp_struct.gr_mem = [user] + + check = checks.LxdGroupCheck() + result = check.run() + + assert result is True + + def test_user_not_in_lxd_group(self, mocker): + user = os.environ.get("USER") + grp_struct = Mock() + mocker.patch.object(grp, "getgrnam", return_value=grp_struct) + grp_struct.gr_mem = [] + + check = checks.LxdGroupCheck() + result = check.run() + + assert result is False + assert f"{user} not part of lxd group" in check.message + + +class TestLxdControllerRegistrationCheck: + def test_lxd_controller_exists(self, mocker): + jsh = Mock() + mocker.patch.object(checks, "JujuStepHelper", return_value=jsh) + jsh.get_controllers.return_value = ["lxdcloud"] + + check = checks.LXDJujuControllerRegistrationCheck() + result = check.run() + + assert result is True + + def test_lxd_controller_does_not_exist(self, mocker): + jsh = Mock() + mocker.patch.object(checks, "JujuStepHelper", return_value=jsh) + jsh.get_controllers.return_value = [] + + check = checks.LXDJujuControllerRegistrationCheck() + result = check.run() + + assert result is False + assert "Missing Juju controller on LXD" in check.message diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_microk8s.py b/sunbeam-python/tests/unit/sunbeam/steps/test_microk8s.py index a624b65f..aee48ce8 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_microk8s.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_microk8s.py @@ -25,9 +25,11 @@ ApplicationNotFoundException, LeaderNotFoundException, ) -from sunbeam.steps.microk8s import ( +from sunbeam.steps.k8s import ( CREDENTIAL_SUFFIX, K8S_CLOUD_SUFFIX, +) +from sunbeam.steps.microk8s import ( AddMicrok8sCloudStep, StoreMicrok8sConfigStep, ) diff --git a/sunbeam-python/tests/unit/sunbeam/test_clusterd.py b/sunbeam-python/tests/unit/sunbeam/test_clusterd.py index d3ec3ae1..d5ffc07c 100644 --- a/sunbeam-python/tests/unit/sunbeam/test_clusterd.py +++ b/sunbeam-python/tests/unit/sunbeam/test_clusterd.py @@ -13,13 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import AsyncMock, MagicMock, Mock +import json +import subprocess +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from requests.exceptions import HTTPError import sunbeam.clusterd.service as service +import sunbeam.core.questions from sunbeam.clusterd.cluster import ClusterService +from sunbeam.clusterd.service import ConfigItemNotFoundException from sunbeam.core.common import ResultType from sunbeam.core.juju import ApplicationNotFoundException from sunbeam.steps.clusterd import ( @@ -46,6 +50,12 @@ def model(): return "test-model" +@pytest.fixture() +def load_answers(): + with patch.object(sunbeam.core.questions, "load_answers") as p: + yield p + + class TestClusterdSteps: """Unit tests for sunbeam clusterd steps.""" @@ -606,6 +616,86 @@ def test_init_step(self): "10.0.0.0/24", ) == ["10.0.0.6:17070"] + def test_skip(self, cclient, snap, run, load_answers): + controller_name = "lxdcloud" + endpoints = ["10.0.0.1:17070", "[fd42:9331:57e6:2088:216:3eff:fe82:2bb6]:17070"] + management_cidr = "10.0.0.0/24" + + controller = json.dumps( + {controller_name: {"details": {"api-endpoints": endpoints}}} + ) + run.return_value = subprocess.CompletedProcess( + args={}, returncode=0, stdout=controller + ) + + load_answers.return_value = {"bootstrap": {"management_cidr": management_cidr}} + cclient.cluster.get_config.side_effect = ConfigItemNotFoundException() + + step = ClusterUpdateJujuControllerStep(cclient, controller_name) + result = step.is_skip() + + assert result.result_type == ResultType.COMPLETED + + def test_skip_when_controller_details_exist_in_clusterdb( + self, cclient, snap, run, load_answers + ): + controller_name = "lxdcloud" + endpoints = ["10.0.0.1:17070", "[fd42:9331:57e6:2088:216:3eff:fe82:2bb6]:17070"] + management_cidr = "10.0.0.0/24" + + controller = json.dumps( + {controller_name: {"details": {"api-endpoints": endpoints}}} + ) + run.return_value = subprocess.CompletedProcess( + args={}, returncode=0, stdout=controller + ) + + load_answers.return_value = {"bootstrap": {"management_cidr": management_cidr}} + cclient.cluster.get_config.return_value = json.dumps( + { + "name": controller_name, + "api_endpoints": [endpoints[0]], + "ca_cert": "TMP_CA_CERT", + "is_external": False, + } + ) + + step = ClusterUpdateJujuControllerStep(cclient, controller_name) + result = step.is_skip() + + assert result.result_type == ResultType.SKIPPED + + def test_skip_reapply_step_with_no_endpoints_filter( + self, cclient, snap, run, load_answers + ): + controller_name = "lxdcloud" + endpoints = ["10.0.0.1:17070", "[fd42:9331:57e6:2088:216:3eff:fe82:2bb6]:17070"] + management_cidr = "10.0.0.0/24" + + controller = json.dumps( + {controller_name: {"details": {"api-endpoints": endpoints}}} + ) + run.return_value = subprocess.CompletedProcess( + args={}, returncode=0, stdout=controller + ) + + load_answers.return_value = {"bootstrap": {"management_cidr": management_cidr}} + cclient.cluster.get_config.return_value = json.dumps( + { + "name": controller_name, + "api_endpoints": [endpoints[0]], + "ca_cert": "TMP_CA_CERT", + "is_external": False, + } + ) + + step = ClusterUpdateJujuControllerStep( + cclient, controller_name, filter_endpoints=False + ) + result = step.is_skip() + + assert result.result_type == ResultType.COMPLETED + @pytest.fixture() def manifest():